/** * 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; pauseDownload: () => void; resumeDownload: () => void; cancelDownload: () => void; deleteOfflineData: () => Promise; checkForUpdates: () => Promise; // Info projectInfo: OfflineProject | null; estimatedSize: number; formatSize: (bytes: number) => string; } /** * Format bytes to human-readable string */ const formatBytes = (bytes: number): string => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; }; export function useOfflineMode( options: UseOfflineModeOptions, ): UseOfflineModeResult { const { projectId, projectSlug, projectName, pages, enabled = true, } = options; const [projectInfo, setProjectInfo] = useState(null); const [discoveredAssets, setDiscoveredAssets] = useState([]); const [status, setStatus] = useState('not_downloaded'); const [progress, setProgress] = useState(0); const [downloadedAssets, setDownloadedAssets] = useState(0); const [totalAssets, setTotalAssets] = useState(0); const [downloadedBytes, setDownloadedBytes] = useState(0); const [totalBytes, setTotalBytes] = useState(0); const [error, setError] = useState(null); const [isPaused, setIsPaused] = useState(false); // Track assets for event-driven progress const assetsRef = useRef([]); 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[1], ); const unsubError = downloadEventBus.on( OFFLINE_CONFIG.events.preloadError as Parameters< typeof downloadEventBus.on >[0], handleError as Parameters[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[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 => { 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 = {}; 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 => { 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, }; }