388 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|