/** * useOfflineMode Hook * * Manages offline mode state and project download functionality. */ import { useState, useEffect, useCallback, useMemo } from 'react'; import axios from 'axios'; import { downloadManager } from '../lib/offline/DownloadManager'; import { StorageManager } from '../lib/offline/StorageManager'; import { OfflineDbManager } from '../lib/offlineDb/OfflineDbManager'; import { downloadEventBus } from '../lib/offline/DownloadEventBus'; import { OFFLINE_CONFIG } from '../config/offline.config'; import { logger } from '../lib/logger'; import type { OfflineProject, OfflineManifest, ProjectOfflineStatus, ProjectDownloadProgressEvent, } from '../types/offline'; interface UseOfflineModeOptions { projectId: string | null; projectSlug?: string; projectName?: string; enabled?: boolean; } interface UseOfflineModeResult { // Status isOfflineCapable: boolean; isDownloaded: boolean; isDownloading: boolean; status: ProjectOfflineStatus; progress: number; downloadedAssets: number; totalAssets: number; downloadedBytes: number; totalBytes: number; error: string | null; // Actions startDownload: () => Promise; 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, enabled = true } = options; const [projectInfo, setProjectInfo] = useState(null); const [manifest, setManifest] = useState(null); 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); // Check if offline mode is supported const isOfflineCapable = useMemo(() => { if (typeof window === 'undefined') return false; return 'serviceWorker' in navigator && 'caches' in window; }, []); // Load project offline status from IndexedDB useEffect(() => { if (!projectId || !enabled) return; const loadProjectInfo = async () => { const info = await OfflineDbManager.getProject(projectId); if (info) { setProjectInfo(info); setStatus(info.status); setDownloadedAssets(info.downloadedAssets); setTotalAssets(info.totalAssets); setDownloadedBytes(info.downloadedSizeBytes); setTotalBytes(info.totalSizeBytes); if (info.totalAssets > 0) { setProgress( Math.round((info.downloadedAssets / info.totalAssets) * 100), ); } } }; loadProjectInfo(); }, [projectId, enabled]); // Listen for progress events useEffect(() => { if (!projectId) return; const handleProgress = (data: ProjectDownloadProgressEvent) => { if (data.projectId !== projectId) return; setProgress(data.progress); setDownloadedAssets(data.downloadedAssets); setTotalAssets(data.totalAssets); setDownloadedBytes(data.downloadedBytes); setTotalBytes(data.totalBytes); }; return downloadEventBus.on( OFFLINE_CONFIG.events.projectDownloadProgress as Parameters< typeof downloadEventBus.on >[0], handleProgress as Parameters[1], ); }, [projectId]); // Fetch manifest from backend const fetchManifest = useCallback(async (): Promise => { if (!projectId) return null; try { const response = await axios.get( `/api/projects/${projectId}/offline-manifest`, ); return response.data; } catch (err) { logger.error( '[useOfflineMode] Failed to fetch manifest:', err instanceof Error ? err : { error: err }, ); return null; } }, [projectId]); // Start download const startDownload = useCallback(async (): Promise => { if (!projectId || !enabled) return; setError(null); setStatus('downloading'); setIsPaused(false); try { // Fetch manifest const manifestData = await fetchManifest(); if (!manifestData) { throw new Error('Failed to fetch offline manifest'); } setManifest(manifestData); setTotalAssets(manifestData.assets.length); setTotalBytes(manifestData.totalSizeBytes); // Create or update project record const projectRecord: OfflineProject = { id: projectId, slug: projectSlug || '', name: projectName || '', status: 'downloading', totalAssets: manifestData.assets.length, downloadedAssets: 0, totalSizeBytes: manifestData.totalSizeBytes, downloadedSizeBytes: 0, version: manifestData.version, }; await OfflineDbManager.upsertProject(projectRecord); setProjectInfo(projectRecord); // Check storage quota const quota = await StorageManager.getStorageQuota(); if (!quota.canStore(manifestData.totalSizeBytes)) { throw new Error('Insufficient storage space'); } // Add all assets to download queue let downloadedCount = 0; let downloadedSize = 0; for (const asset of manifestData.assets) { // Check if already downloaded const hasAsset = await StorageManager.hasAsset(asset.url); if (hasAsset) { downloadedCount++; downloadedSize += asset.sizeBytes; continue; } await downloadManager.addJob({ assetId: asset.id, projectId, url: asset.url, filename: asset.filename, variantType: asset.variantType, assetType: asset.assetType, priority: asset.assetType === 'image' ? 100 : asset.assetType === 'video' ? 50 : 75, }); } // Update initial progress setDownloadedAssets(downloadedCount); setDownloadedBytes(downloadedSize); if (downloadedCount === manifestData.assets.length) { // All already downloaded setStatus('downloaded'); setProgress(100); await OfflineDbManager.updateProjectStatus(projectId, 'downloaded'); } else { // Track progress const trackProgress = async () => { const projectAssets = await OfflineDbManager.getProjectAssets(projectId); const downloaded = projectAssets.length; const dlBytes = projectAssets.reduce( (sum, a) => sum + a.sizeBytes, 0, ); setDownloadedAssets(downloaded); setDownloadedBytes(dlBytes); const prog = Math.round( (downloaded / manifestData.assets.length) * 100, ); setProgress(prog); await OfflineDbManager.updateProjectProgress( projectId, downloaded, dlBytes, ); downloadEventBus.emitProjectProgress({ projectId, progress: prog, downloadedAssets: downloaded, totalAssets: manifestData.assets.length, downloadedBytes: dlBytes, totalBytes: manifestData.totalSizeBytes, }); if (downloaded === manifestData.assets.length) { setStatus('downloaded'); await OfflineDbManager.updateProjectStatus(projectId, 'downloaded'); downloadEventBus.emitProjectComplete({ projectId }); } }; // Poll for progress updates const progressInterval = setInterval(trackProgress, 1000); // Store cleanup reference (could be used for unmount) // Note: This is fire-and-forget, caller should use cancelDownload for cleanup setTimeout( () => { // Auto-stop polling after 10 minutes max clearInterval(progressInterval); }, 10 * 60 * 1000, ); } } catch (err) { const message = err instanceof Error ? err.message : 'Download failed'; setError(message); setStatus('error'); await OfflineDbManager.updateProjectStatus(projectId, 'error'); } }, [projectId, projectSlug, projectName, enabled, fetchManifest]); // Pause download const pauseDownload = useCallback(() => { downloadManager.pauseAll(); setIsPaused(true); }, []); // Resume download const resumeDownload = useCallback(() => { downloadManager.resumeAll(); setIsPaused(false); }, []); // Cancel download const cancelDownload = useCallback(() => { if (!projectId) return; downloadManager.cancelProjectDownloads(projectId); setStatus('not_downloaded'); setProgress(0); setDownloadedAssets(0); setDownloadedBytes(0); setIsPaused(false); setError(null); OfflineDbManager.deleteProject(projectId); setProjectInfo(null); }, [projectId]); // Delete offline data const deleteOfflineData = useCallback(async () => { if (!projectId) return; await StorageManager.deleteProjectAssets(projectId); await OfflineDbManager.deleteProject(projectId); setProjectInfo(null); setStatus('not_downloaded'); setProgress(0); setDownloadedAssets(0); setTotalAssets(0); setDownloadedBytes(0); setTotalBytes(0); }, [projectId]); // Check for updates const checkForUpdates = useCallback(async (): Promise => { if (!projectId || !projectInfo) return false; try { const latestManifest = await fetchManifest(); if (!latestManifest) return false; if (latestManifest.version !== projectInfo.version) { setStatus('outdated'); await OfflineDbManager.updateProjectStatus(projectId, 'outdated'); return true; } return false; } catch { return false; } }, [projectId, projectInfo, fetchManifest]); // Computed values const isDownloaded = status === 'downloaded'; const isDownloading = status === 'downloading' && !isPaused; const estimatedSize = manifest?.totalSizeBytes || totalBytes; return { isOfflineCapable, isDownloaded, isDownloading, status, progress, downloadedAssets, totalAssets, downloadedBytes, totalBytes, error, startDownload, pauseDownload, resumeDownload, cancelDownload, deleteOfflineData, checkForUpdates, projectInfo, estimatedSize, formatSize: formatBytes, }; }