39948-vm/frontend/src/hooks/useOfflineMode.ts
2026-03-24 08:20:27 +04:00

388 lines
11 KiB
TypeScript

/**
* 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<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, enabled = true } = options;
const [projectInfo, setProjectInfo] = useState<OfflineProject | null>(null);
const [manifest, setManifest] = useState<OfflineManifest | null>(null);
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);
// 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<typeof downloadEventBus.on>[1],
);
}, [projectId]);
// Fetch manifest from backend
const fetchManifest =
useCallback(async (): Promise<OfflineManifest | null> => {
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<void> => {
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<boolean> => {
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,
};
}