39948-vm/frontend/src/hooks/useOfflineMode.ts
2026-04-14 20:28:58 +04:00

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,
};
}