1101 lines
31 KiB
Markdown
1101 lines
31 KiB
Markdown
# Frontend Context Module
|
||
|
||
## Overview
|
||
|
||
The Context module provides **React Context-based state management** for cross-cutting concerns that need to be shared across the component tree without prop drilling. Currently, the module contains a single context for managing download/preload progress in the PWA offline system.
|
||
|
||
**Location:** `frontend/src/context/`
|
||
|
||
**Total Files:** 1 file (~256 LOC)
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
```
|
||
frontend/src/context/
|
||
└── DownloadContext.tsx (256 LOC) # Download progress state management
|
||
|
||
Related Files:
|
||
├── lib/offline/DownloadEventBus.ts (180 LOC) # Event emitter for download events
|
||
├── hooks/usePreloadProgress.ts (221 LOC) # Hook-based alternative
|
||
├── config/offline.config.ts (53 LOC) # Event names, IndexedDB, cache settings
|
||
├── config/preload.config.ts (95 LOC) # Priority weights, auto-cleanup timeouts
|
||
├── types/offline.ts (195 LOC) # Type definitions
|
||
│
|
||
└── components/Offline/
|
||
├── DownloadProgressPanel.tsx (194 LOC) # Progress UI with job list
|
||
├── OfflineStatusIndicator.tsx (62 LOC) # Status badge (online/offline/syncing)
|
||
├── OfflineToggle.tsx (150 LOC) # Download toggle with storage checks
|
||
└── StorageUsageDisplay.tsx (125 LOC) # Storage quota UI with persistence
|
||
```
|
||
|
||
---
|
||
|
||
## DownloadContext (`DownloadContext.tsx`)
|
||
|
||
**Purpose:** Manage download job state and progress for PWA asset preloading.
|
||
|
||
**Lines:** 256 LOC
|
||
|
||
### Context State Interface
|
||
|
||
```typescript
|
||
interface DownloadState {
|
||
jobs: PreloadJob[]; // All tracked download jobs
|
||
activeCount: number; // Jobs currently downloading/queued
|
||
completedCount: number; // Jobs completed successfully
|
||
errorCount: number; // Jobs that failed
|
||
totalProgress: number; // Overall progress percentage (0-100)
|
||
isDownloading: boolean; // True if any job is active
|
||
}
|
||
```
|
||
|
||
### Context Value Interface
|
||
|
||
```typescript
|
||
interface DownloadContextValue extends DownloadState {
|
||
clearJob: (id: string) => void; // Remove a specific job
|
||
clearAllCompleted: () => void; // Clear all completed jobs
|
||
clearAllErrors: () => void; // Clear all errored jobs
|
||
clearAll: () => void; // Clear all jobs
|
||
}
|
||
```
|
||
|
||
### PreloadJob Interface
|
||
|
||
```typescript
|
||
interface PreloadJob {
|
||
id: string; // Unique job identifier
|
||
assetId: string; // Asset being downloaded
|
||
url: string; // Download URL
|
||
filename: string; // Display filename
|
||
progress: number; // 0-100 percent
|
||
status: PreloadJobStatus; // 'queued' | 'downloading' | 'completed' | 'error' | 'paused'
|
||
bytesLoaded: number; // Bytes downloaded
|
||
totalBytes: number; // Total file size
|
||
pageId?: string; // Associated tour page
|
||
variantType?: AssetVariantType; // thumbnail, preview, webp, etc.
|
||
assetType?: AssetType; // image, video, audio, transition
|
||
error?: string; // Error message if failed
|
||
addedAt: number; // Timestamp added to queue
|
||
startedAt?: number; // Timestamp download started
|
||
completedAt?: number; // Timestamp completed
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Provider Implementation
|
||
|
||
### DownloadProvider Component
|
||
|
||
```typescript
|
||
export function DownloadProvider({ children }: DownloadProviderProps) {
|
||
const [jobs, setJobs] = useState<PreloadJob[]>([]);
|
||
|
||
// Subscribe to DownloadEventBus events
|
||
useEffect(() => {
|
||
const handleStart = (data: PreloadStartEvent) => {
|
||
// Create new job from event data
|
||
};
|
||
return downloadEventBus.on(OFFLINE_CONFIG.events.preloadStart, handleStart);
|
||
}, []);
|
||
|
||
// Similar subscriptions for progress, complete, error events...
|
||
|
||
// Computed values with useMemo
|
||
const value = useMemo<DownloadContextValue>(() => ({
|
||
jobs,
|
||
activeCount: jobs.filter(j => j.status === 'downloading' || j.status === 'queued').length,
|
||
completedCount: jobs.filter(j => j.status === 'completed').length,
|
||
errorCount: jobs.filter(j => j.status === 'error').length,
|
||
totalProgress: calculateTotalProgress(jobs),
|
||
isDownloading: activeCount > 0,
|
||
clearJob,
|
||
clearAllCompleted,
|
||
clearAllErrors,
|
||
clearAll,
|
||
}), [jobs, clearJob, clearAllCompleted, clearAllErrors, clearAll]);
|
||
|
||
return (
|
||
<DownloadContext.Provider value={value}>
|
||
{children}
|
||
</DownloadContext.Provider>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Event Handling
|
||
|
||
The provider subscribes to four events from `DownloadEventBus`:
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Event Flow │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ usePreloadOrchestrator │
|
||
│ DownloadManager │
|
||
│ (Emit events via downloadEventBus) │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
┌─────────────────────┼─────────────────────┐
|
||
▼ ▼ ▼
|
||
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
|
||
│ preloadStart │ │preloadProgress│ │preloadComplete│
|
||
│ │ │ │ │ │
|
||
│ Create new │ │ Update │ │ Mark complete │
|
||
│ job entry │ │ progress % │ │ Auto-remove │
|
||
│ │ │ bytesLoaded │ │ after 3s │
|
||
└───────────────┘ └───────────────┘ └───────────────┘
|
||
│ │ │
|
||
└─────────────────────┼─────────────────────┘
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ preloadError │
|
||
│ │
|
||
│ Mark job as error, store error message │
|
||
│ Auto-remove after 10s │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ DownloadContext State │
|
||
│ │
|
||
│ jobs[], activeCount, completedCount, errorCount, │
|
||
│ totalProgress, isDownloading │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Consumer Components │
|
||
│ │
|
||
│ DownloadProgressPanel, OfflineStatusIndicator, etc. │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### Event Handlers
|
||
|
||
```typescript
|
||
// Handle preload start - create new job
|
||
const handleStart = (data: PreloadStartEvent) => {
|
||
const newJob: PreloadJob = {
|
||
id: data.jobId,
|
||
assetId: data.assetId,
|
||
url: data.url,
|
||
filename: data.url.split('/').pop() || 'unknown',
|
||
progress: 0,
|
||
status: 'downloading',
|
||
bytesLoaded: 0,
|
||
totalBytes: 0,
|
||
addedAt: Date.now(),
|
||
startedAt: Date.now(),
|
||
};
|
||
|
||
setJobs((prev) => {
|
||
// Avoid duplicates
|
||
if (prev.some((j) => j.id === data.jobId)) {
|
||
return prev;
|
||
}
|
||
return [...prev, newJob];
|
||
});
|
||
};
|
||
|
||
// Handle preload progress - update existing job
|
||
const handleProgress = (data: PreloadProgressEvent) => {
|
||
setJobs((prev) =>
|
||
updateJobInArray(prev, data.jobId, {
|
||
progress: data.progress,
|
||
bytesLoaded: data.bytesLoaded,
|
||
totalBytes: data.totalBytes,
|
||
status: 'downloading',
|
||
}),
|
||
);
|
||
};
|
||
|
||
// Handle preload complete - mark done and auto-remove
|
||
const handleComplete = (data: PreloadCompleteEvent) => {
|
||
setJobs((prev) =>
|
||
updateJobInArray(prev, data.jobId, {
|
||
status: 'completed',
|
||
progress: 100,
|
||
completedAt: Date.now(),
|
||
}),
|
||
);
|
||
|
||
// Auto-remove after 3 seconds
|
||
setTimeout(() => {
|
||
setJobs((prev) => prev.filter((j) => j.id !== data.jobId));
|
||
}, PRELOAD_CONFIG.autoRemove.completedMs);
|
||
};
|
||
|
||
// Handle preload error - mark failed and auto-remove
|
||
const handleError = (data: PreloadErrorEvent) => {
|
||
setJobs((prev) =>
|
||
updateJobInArray(prev, data.jobId, {
|
||
status: 'error',
|
||
error: data.error,
|
||
}),
|
||
);
|
||
|
||
// Auto-remove after 10 seconds
|
||
setTimeout(() => {
|
||
setJobs((prev) => prev.filter((j) => j.id !== data.jobId));
|
||
}, PRELOAD_CONFIG.autoRemove.errorMs);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Consumer Hooks
|
||
|
||
### useDownloadContext (Required)
|
||
|
||
```typescript
|
||
export function useDownloadContext(): DownloadContextValue {
|
||
const context = useContext(DownloadContext);
|
||
if (!context) {
|
||
throw new Error(
|
||
'useDownloadContext must be used within a DownloadProvider',
|
||
);
|
||
}
|
||
return context;
|
||
}
|
||
```
|
||
|
||
**Usage:**
|
||
|
||
```typescript
|
||
import { useDownloadContext } from '@/context/DownloadContext';
|
||
|
||
function MyComponent() {
|
||
const {
|
||
jobs,
|
||
activeCount,
|
||
completedCount,
|
||
totalProgress,
|
||
isDownloading,
|
||
clearJob,
|
||
clearAllCompleted,
|
||
} = useDownloadContext();
|
||
|
||
return (
|
||
<div>
|
||
{isDownloading && <span>Downloading... {totalProgress}%</span>}
|
||
<ul>
|
||
{jobs.map(job => (
|
||
<li key={job.id}>
|
||
{job.filename}: {job.progress}%
|
||
<button onClick={() => clearJob(job.id)}>×</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### useDownloadContextOptional (Safe)
|
||
|
||
```typescript
|
||
export function useDownloadContextOptional(): DownloadContextValue | null {
|
||
return useContext(DownloadContext);
|
||
}
|
||
```
|
||
|
||
**Usage:** For components that may render outside the provider:
|
||
|
||
```typescript
|
||
function SafeComponent() {
|
||
const downloadCtx = useDownloadContextOptional();
|
||
|
||
// Works even outside DownloadProvider
|
||
if (downloadCtx?.isDownloading) {
|
||
return <span>Downloading...</span>;
|
||
}
|
||
return null;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Provider Setup
|
||
|
||
The provider is mounted at the application root in `_app.tsx`:
|
||
|
||
```typescript
|
||
// pages/_app.tsx
|
||
import { DownloadProvider } from '../context/DownloadContext';
|
||
|
||
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
|
||
const getLayout = Component.getLayout || ((page) => page);
|
||
|
||
return (
|
||
<Provider store={store}>
|
||
<DownloadProvider>
|
||
{getLayout(
|
||
<>
|
||
<Head>
|
||
{/* Meta tags, favicon, PWA manifest */}
|
||
</Head>
|
||
<ErrorBoundary>
|
||
<Component {...pageProps} />
|
||
</ErrorBoundary>
|
||
<IntroGuide steps={steps} stepsEnabled={stepsEnabled} onExit={handleExit} />
|
||
</>
|
||
)}
|
||
</DownloadProvider>
|
||
</Provider>
|
||
);
|
||
}
|
||
|
||
export default appWithTranslation(MyApp);
|
||
```
|
||
|
||
### Provider Hierarchy
|
||
|
||
```
|
||
<Provider store={store}> ← Redux
|
||
<DownloadProvider> ← React Context (download progress)
|
||
{getLayout( ← Per-page layout (LayoutAuthenticated/LayoutGuest)
|
||
<>
|
||
<Head /> ← Meta tags, PWA config
|
||
<ErrorBoundary>
|
||
<Component /> ← Page component
|
||
</ErrorBoundary>
|
||
<IntroGuide /> ← Onboarding tour
|
||
</>
|
||
)}
|
||
</DownloadProvider>
|
||
</Provider>
|
||
```
|
||
|
||
---
|
||
|
||
## Related: DownloadEventBus
|
||
|
||
The context subscribes to events from `DownloadEventBus`, a browser-native event emitter:
|
||
|
||
```typescript
|
||
// lib/offline/DownloadEventBus.ts
|
||
class DownloadEventBusClass {
|
||
private listeners: Map<string, Set<EventCallback<unknown>>> = new Map();
|
||
|
||
on<K extends keyof EventMap>(event: K, callback: EventCallback<EventMap[K]>): () => void;
|
||
off<K extends keyof EventMap>(event: K, callback: EventCallback<EventMap[K]>): void;
|
||
emit<K extends keyof EventMap>(event: K, data: EventMap[K]): void;
|
||
once<K extends keyof EventMap>(event: K, callback: EventCallback<EventMap[K]>): () => void;
|
||
removeAllListeners(event?: keyof EventMap): void;
|
||
listenerCount(event: keyof EventMap): number;
|
||
|
||
// Convenience methods
|
||
emitPreloadStart(data: PreloadStartEvent): void;
|
||
emitPreloadProgress(data: PreloadProgressEvent): void;
|
||
emitPreloadComplete(data: PreloadCompleteEvent): void;
|
||
emitPreloadError(data: PreloadErrorEvent): void;
|
||
emitProjectProgress(data: ProjectDownloadProgressEvent): void;
|
||
emitProjectComplete(data: ProjectDownloadCompleteEvent): void;
|
||
emitQueueUpdate(): void;
|
||
}
|
||
|
||
export const downloadEventBus = new DownloadEventBusClass();
|
||
```
|
||
|
||
### Event Types
|
||
|
||
```typescript
|
||
// Defined in config/offline.config.ts
|
||
export const OFFLINE_CONFIG = {
|
||
events: {
|
||
preloadStart: 'asset-preload-start',
|
||
preloadProgress: 'asset-preload-progress',
|
||
preloadComplete: 'asset-preload-complete',
|
||
preloadError: 'asset-preload-error',
|
||
projectDownloadProgress: 'project-download-progress',
|
||
projectDownloadComplete: 'project-download-complete',
|
||
queueUpdate: 'queue-update',
|
||
},
|
||
// ...
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Related: usePreloadProgress Hook
|
||
|
||
An alternative hook-based approach that also subscribes to `DownloadEventBus`:
|
||
|
||
```typescript
|
||
// hooks/usePreloadProgress.ts
|
||
export function usePreloadProgress(): UsePreloadProgressResult {
|
||
const [jobs, setJobs] = useState<PreloadJob[]>([]);
|
||
|
||
// Same event subscription pattern as DownloadContext
|
||
useEffect(() => {
|
||
return downloadEventBus.on(
|
||
OFFLINE_CONFIG.events.preloadStart,
|
||
handleStart,
|
||
);
|
||
}, []);
|
||
|
||
// ... similar to DownloadContext
|
||
|
||
return {
|
||
jobs,
|
||
activeCount,
|
||
completedCount,
|
||
errorCount,
|
||
totalProgress,
|
||
isActive,
|
||
clearJob,
|
||
clearAllCompleted,
|
||
clearAllErrors,
|
||
};
|
||
}
|
||
```
|
||
|
||
### Context vs Hook Comparison
|
||
|
||
| Aspect | DownloadContext | usePreloadProgress |
|
||
|--------|-----------------|-------------------|
|
||
| **State Sharing** | Shared across tree | Per-component instance |
|
||
| **Deduplication** | Single subscription | Multiple subscriptions |
|
||
| **Use Case** | Global UI (headers) | Local UI (panels) |
|
||
| **Performance** | Better for many consumers | Better for single consumer |
|
||
| **Coupling** | Requires Provider | Standalone |
|
||
|
||
**Recommendation:** Use `DownloadContext` for shared global state (status indicators in headers), use `usePreloadProgress` for isolated components (embedded progress panels).
|
||
|
||
---
|
||
|
||
## Consumer Components
|
||
|
||
### DownloadProgressPanel
|
||
|
||
Shows detailed download progress with job list:
|
||
|
||
```typescript
|
||
import {
|
||
mdiPause, mdiPlay, mdiClose, mdiCheck, mdiAlertCircle, mdiLoading,
|
||
} from '@mdi/js';
|
||
import Icon from '@mdi/react';
|
||
import { usePreloadProgress } from '../../hooks/usePreloadProgress';
|
||
|
||
interface DownloadProgressPanelProps {
|
||
className?: string;
|
||
maxItems?: number;
|
||
showWhenEmpty?: boolean;
|
||
}
|
||
|
||
export function DownloadProgressPanel({
|
||
className = '',
|
||
maxItems = 5,
|
||
showWhenEmpty = false,
|
||
}: DownloadProgressPanelProps) {
|
||
const {
|
||
jobs,
|
||
activeCount,
|
||
completedCount,
|
||
errorCount,
|
||
totalProgress,
|
||
isActive,
|
||
clearJob,
|
||
clearAllCompleted,
|
||
clearAllErrors,
|
||
} = usePreloadProgress();
|
||
|
||
if (!showWhenEmpty && jobs.length === 0) return null;
|
||
|
||
const visibleJobs = jobs.slice(0, maxItems);
|
||
const hiddenCount = jobs.length - maxItems;
|
||
|
||
return (
|
||
<div className={`bg-white border rounded-lg shadow-lg p-3 min-w-[280px] ${className}`}>
|
||
{/* Header */}
|
||
<div className='flex items-center justify-between mb-2 pb-2 border-b'>
|
||
<span className='text-sm font-medium'>Downloads</span>
|
||
{isActive && <span className='text-xs text-blue-600'>{activeCount} active</span>}
|
||
{completedCount > 0 && (
|
||
<button onClick={clearAllCompleted} className='text-xs text-gray-500'>
|
||
Clear completed
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Overall progress */}
|
||
{isActive && (
|
||
<div className='mb-3'>
|
||
<div className='flex justify-between text-xs text-gray-500 mb-1'>
|
||
<span>Overall progress</span>
|
||
<span>{totalProgress}%</span>
|
||
</div>
|
||
<div className='w-full h-1.5 bg-gray-200 rounded-full overflow-hidden'>
|
||
<div className='h-full bg-blue-500' style={{ width: `${totalProgress}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Job list */}
|
||
<div className='space-y-2'>
|
||
{visibleJobs.map((job) => (
|
||
<div key={job.id} className='flex items-center gap-2 p-2 bg-gray-50 rounded'>
|
||
{/* Status icon */}
|
||
{job.status === 'downloading' && (
|
||
<Icon path={mdiLoading} size={0.7} className='text-blue-500 animate-spin' />
|
||
)}
|
||
{job.status === 'completed' && (
|
||
<Icon path={mdiCheck} size={0.7} className='text-green-500' />
|
||
)}
|
||
{job.status === 'error' && (
|
||
<Icon path={mdiAlertCircle} size={0.7} className='text-red-500' />
|
||
)}
|
||
|
||
{/* Info */}
|
||
<div className='flex-1 min-w-0'>
|
||
<div className='text-xs font-medium truncate'>{job.filename}</div>
|
||
{job.status === 'downloading' && (
|
||
<div className='w-full h-1 bg-gray-200 rounded-full mt-1'>
|
||
<div className='h-full bg-blue-500' style={{ width: `${job.progress}%` }} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Close button */}
|
||
<button onClick={() => clearJob(job.id)} className='p-0.5 text-gray-400'>
|
||
<Icon path={mdiClose} size={0.5} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
|
||
{hiddenCount > 0 && (
|
||
<div className='text-xs text-gray-500 text-center py-1'>
|
||
+{hiddenCount} more items
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### OfflineStatusIndicator
|
||
|
||
Small badge showing sync status:
|
||
|
||
```typescript
|
||
import { mdiCloudCheck, mdiCloudOffOutline, mdiCloudSync } from '@mdi/js';
|
||
import Icon from '@mdi/react';
|
||
import { useNetworkAware } from '../../hooks/useNetworkAware';
|
||
import { usePreloadProgress } from '../../hooks/usePreloadProgress';
|
||
|
||
interface OfflineStatusIndicatorProps {
|
||
className?: string;
|
||
showLabel?: boolean;
|
||
projectId?: string | null;
|
||
}
|
||
|
||
export function OfflineStatusIndicator({
|
||
className = '',
|
||
showLabel = false,
|
||
}: OfflineStatusIndicatorProps) {
|
||
const { networkInfo } = useNetworkAware();
|
||
const { isActive, totalProgress } = usePreloadProgress();
|
||
|
||
// Determine status
|
||
let icon = mdiCloudCheck;
|
||
let label = 'Online';
|
||
let colorClass = 'text-green-500';
|
||
let bgClass = 'bg-green-50';
|
||
|
||
if (!networkInfo.isOnline) {
|
||
icon = mdiCloudOffOutline;
|
||
label = 'Offline';
|
||
colorClass = 'text-gray-500';
|
||
bgClass = 'bg-gray-100';
|
||
} else if (isActive) {
|
||
icon = mdiCloudSync;
|
||
label = `Syncing ${totalProgress}%`;
|
||
colorClass = 'text-blue-500';
|
||
bgClass = 'bg-blue-50';
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={`flex items-center gap-1.5 px-2 py-1 rounded-full ${bgClass} ${className}`}
|
||
title={label}
|
||
>
|
||
<Icon
|
||
path={icon}
|
||
size={0.6}
|
||
className={`${colorClass} ${isActive ? 'animate-pulse' : ''}`}
|
||
/>
|
||
{showLabel && (
|
||
<span className={`text-xs font-medium ${colorClass}`}>{label}</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### OfflineToggle
|
||
|
||
Toggle button for offline download:
|
||
|
||
```typescript
|
||
import { mdiCloudDownload, mdiCloudCheck, mdiCloudOff, mdiDelete } from '@mdi/js';
|
||
import Icon from '@mdi/react';
|
||
import BaseButton from '../BaseButton';
|
||
import { useOfflineMode } from '../../hooks/useOfflineMode';
|
||
import { useStorageQuota } from '../../hooks/useStorageQuota';
|
||
|
||
interface OfflineToggleProps {
|
||
projectId: string | null;
|
||
projectSlug?: string;
|
||
projectName?: string;
|
||
className?: string;
|
||
showLabel?: boolean;
|
||
size?: 'small' | 'medium' | 'large';
|
||
}
|
||
|
||
export function OfflineToggle({
|
||
projectId,
|
||
projectSlug,
|
||
projectName,
|
||
className = '',
|
||
showLabel = true,
|
||
size = 'medium',
|
||
}: OfflineToggleProps) {
|
||
const {
|
||
isOfflineCapable,
|
||
isDownloaded,
|
||
isDownloading,
|
||
status,
|
||
progress,
|
||
startDownload,
|
||
pauseDownload,
|
||
resumeDownload,
|
||
cancelDownload,
|
||
deleteOfflineData,
|
||
estimatedSize,
|
||
formatSize,
|
||
} = useOfflineMode({ projectId, projectSlug, projectName });
|
||
|
||
const { canStore, isWarning, isCritical } = useStorageQuota();
|
||
|
||
if (!isOfflineCapable) return null;
|
||
|
||
const handleClick = () => {
|
||
if (isDownloaded) {
|
||
if (confirm('Remove offline data for this project?')) {
|
||
deleteOfflineData();
|
||
}
|
||
} else if (isDownloading) {
|
||
pauseDownload();
|
||
} else if (status === 'error') {
|
||
resumeDownload();
|
||
} else {
|
||
if (isCritical) {
|
||
alert('Storage space is critically low.');
|
||
return;
|
||
}
|
||
startDownload();
|
||
}
|
||
};
|
||
|
||
// Determine icon and label based on status
|
||
let icon = mdiCloudDownload;
|
||
let label = 'Download for offline';
|
||
let color: 'info' | 'success' | 'danger' | 'warning' = 'info';
|
||
|
||
if (isDownloaded) {
|
||
icon = mdiCloudCheck;
|
||
label = 'Available offline';
|
||
color = 'success';
|
||
} else if (isDownloading) {
|
||
label = `Downloading ${progress}%`;
|
||
} else if (status === 'error') {
|
||
icon = mdiCloudOff;
|
||
label = 'Retry download';
|
||
color = 'danger';
|
||
}
|
||
|
||
return (
|
||
<div className={`flex items-center gap-2 ${className}`}>
|
||
<BaseButton
|
||
small={size === 'small'}
|
||
color={color}
|
||
icon={icon}
|
||
label={showLabel ? label : undefined}
|
||
onClick={handleClick}
|
||
disabled={!canStore(estimatedSize) && !isDownloaded}
|
||
/>
|
||
{isDownloaded && (
|
||
<button onClick={() => deleteOfflineData()} title='Remove offline data'>
|
||
<Icon path={mdiDelete} size={0.7} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### StorageUsageDisplay
|
||
|
||
Visual storage quota indicator:
|
||
|
||
```typescript
|
||
import { mdiDatabase } from '@mdi/js';
|
||
import Icon from '@mdi/react';
|
||
import { useStorageQuota } from '../../hooks/useStorageQuota';
|
||
|
||
interface StorageUsageDisplayProps {
|
||
className?: string;
|
||
showDetails?: boolean;
|
||
compact?: boolean;
|
||
}
|
||
|
||
export function StorageUsageDisplay({
|
||
className = '',
|
||
showDetails = true,
|
||
compact = false,
|
||
}: StorageUsageDisplayProps) {
|
||
const {
|
||
usage,
|
||
quota,
|
||
percentUsed,
|
||
isLoading,
|
||
isWarning,
|
||
isCritical,
|
||
isPersisted,
|
||
formatSize,
|
||
requestPersistence,
|
||
} = useStorageQuota();
|
||
|
||
if (isLoading) {
|
||
return <div className='animate-pulse h-4 bg-gray-200 rounded w-32' />;
|
||
}
|
||
|
||
let barColor = 'bg-blue-500';
|
||
let textColor = 'text-gray-600';
|
||
|
||
if (isCritical) {
|
||
barColor = 'bg-red-500';
|
||
textColor = 'text-red-600';
|
||
} else if (isWarning) {
|
||
barColor = 'bg-yellow-500';
|
||
textColor = 'text-yellow-600';
|
||
}
|
||
|
||
return (
|
||
<div className={`space-y-2 ${className}`}>
|
||
<div className='flex items-center justify-between'>
|
||
<div className='flex items-center gap-2'>
|
||
<Icon path={mdiDatabase} size={0.7} className='text-gray-500' />
|
||
<span className='text-sm font-medium text-gray-700'>Storage</span>
|
||
</div>
|
||
{showDetails && (
|
||
<span className={`text-xs ${textColor}`}>
|
||
{formatSize(usage)} / {formatSize(quota)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className='w-full h-2 bg-gray-200 rounded-full overflow-hidden'>
|
||
<div
|
||
className={`h-full ${barColor} transition-all duration-300`}
|
||
style={{ width: `${Math.min(percentUsed, 100)}%` }}
|
||
/>
|
||
</div>
|
||
|
||
{showDetails && (
|
||
<div className='flex items-center justify-between text-xs'>
|
||
<span className={textColor}>
|
||
{isCritical ? 'Storage critically low' :
|
||
isWarning ? 'Storage running low' :
|
||
`${Math.round(100 - percentUsed)}% available`}
|
||
</span>
|
||
{!isPersisted && (
|
||
<button onClick={requestPersistence} className='text-blue-500'>
|
||
Make persistent
|
||
</button>
|
||
)}
|
||
{isPersisted && (
|
||
<span className='text-green-600'>Persistent storage enabled</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Progress Calculation
|
||
|
||
```typescript
|
||
const calculateTotalProgress = (jobs: PreloadJob[]): number => {
|
||
if (jobs.length === 0) return 0;
|
||
|
||
const activeJobs = jobs.filter(
|
||
(j) => j.status !== 'completed' && j.status !== 'error',
|
||
);
|
||
if (activeJobs.length === 0) return 100;
|
||
|
||
const totalBytes = activeJobs.reduce((sum, j) => sum + j.totalBytes, 0);
|
||
const loadedBytes = activeJobs.reduce((sum, j) => sum + j.bytesLoaded, 0);
|
||
|
||
if (totalBytes === 0) {
|
||
// Fallback to count-based progress
|
||
const completedCount = jobs.filter((j) => j.status === 'completed').length;
|
||
return Math.round((completedCount / jobs.length) * 100);
|
||
}
|
||
|
||
return Math.round((loadedBytes / totalBytes) * 100);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Auto-Cleanup
|
||
|
||
Jobs are automatically removed after completion/error:
|
||
|
||
```typescript
|
||
// From PRELOAD_CONFIG
|
||
autoRemove: {
|
||
completedMs: 3000, // Remove completed jobs after 3 seconds
|
||
errorMs: 10000, // Remove error jobs after 10 seconds
|
||
}
|
||
```
|
||
|
||
This prevents the jobs array from growing indefinitely and provides a brief visual confirmation before cleanup.
|
||
|
||
---
|
||
|
||
## Type Definitions
|
||
|
||
### PreloadJobStatus
|
||
|
||
```typescript
|
||
type PreloadJobStatus =
|
||
| 'queued' // In queue, not yet started
|
||
| 'downloading' // Currently downloading
|
||
| 'completed' // Successfully completed
|
||
| 'error' // Failed with error
|
||
| 'paused'; // Paused by user
|
||
```
|
||
|
||
### AssetVariantType
|
||
|
||
```typescript
|
||
type AssetVariantType =
|
||
| 'thumbnail' // Small preview
|
||
| 'preview' // Medium preview
|
||
| 'webp' // WebP optimized
|
||
| 'mp4_low' // Low-res video
|
||
| 'mp4_high' // High-res video
|
||
| 'original'; // Original file
|
||
```
|
||
|
||
### AssetType
|
||
|
||
```typescript
|
||
type AssetType =
|
||
| 'image' // Image files
|
||
| 'video' // Video files
|
||
| 'audio' // Audio files
|
||
| 'transition' // Transition videos
|
||
| 'other'; // Other file types
|
||
```
|
||
|
||
---
|
||
|
||
## Event Payloads
|
||
|
||
```typescript
|
||
interface PreloadStartEvent {
|
||
jobId: string;
|
||
assetId: string;
|
||
url: string;
|
||
}
|
||
|
||
interface PreloadProgressEvent {
|
||
jobId: string;
|
||
progress: number; // 0-100
|
||
bytesLoaded: number;
|
||
totalBytes: number;
|
||
}
|
||
|
||
interface PreloadCompleteEvent {
|
||
jobId: string;
|
||
assetId: string;
|
||
}
|
||
|
||
interface PreloadErrorEvent {
|
||
jobId: string;
|
||
assetId: string;
|
||
error: string;
|
||
}
|
||
|
||
interface ProjectDownloadProgressEvent {
|
||
projectId: string;
|
||
progress: number;
|
||
downloadedAssets: number;
|
||
totalAssets: number;
|
||
downloadedBytes: number;
|
||
totalBytes: number;
|
||
}
|
||
|
||
interface ProjectDownloadCompleteEvent {
|
||
projectId: string;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Best Practices
|
||
|
||
### 1. Use Optional Hook Outside Provider
|
||
|
||
```typescript
|
||
// Good - won't crash if outside provider
|
||
const ctx = useDownloadContextOptional();
|
||
if (ctx?.isDownloading) { ... }
|
||
|
||
// Bad - will crash if outside provider
|
||
const ctx = useDownloadContext(); // throws Error
|
||
```
|
||
|
||
### 2. Memoize Computed Values
|
||
|
||
```typescript
|
||
// Good - values memoized in context
|
||
const { activeCount, totalProgress } = useDownloadContext();
|
||
|
||
// Avoid recomputing in consumers
|
||
const activeCount = useMemo(
|
||
() => jobs.filter(j => j.status === 'downloading').length,
|
||
[jobs]
|
||
); // Already computed by context
|
||
```
|
||
|
||
### 3. Use Appropriate State Source
|
||
|
||
```typescript
|
||
// For global UI (headers, status bars)
|
||
const ctx = useDownloadContext();
|
||
|
||
// For isolated components
|
||
const progress = usePreloadProgress();
|
||
```
|
||
|
||
### 4. Clean Up Jobs
|
||
|
||
```typescript
|
||
// Clear completed jobs when user dismisses
|
||
<button onClick={clearAllCompleted}>Clear completed</button>
|
||
|
||
// Clear specific job
|
||
<button onClick={() => clearJob(job.id)}>×</button>
|
||
```
|
||
|
||
---
|
||
|
||
## File Inventory
|
||
|
||
| File | LOC | Purpose |
|
||
|------|-----|---------|
|
||
| `DownloadContext.tsx` | 256 | Context provider and hooks |
|
||
| `DownloadEventBus.ts` | 180 | Event emitter (related) |
|
||
| `usePreloadProgress.ts` | 221 | Hook alternative (related) |
|
||
| `offline.ts` (types) | 195 | Type definitions (related) |
|
||
| `offline.config.ts` | 53 | Event names, storage config (related) |
|
||
| `preload.config.ts` | 95 | Priority, auto-cleanup (related) |
|
||
| **Core Context** | **256** | |
|
||
| **With Related** | **1,000** | |
|
||
|
||
---
|
||
|
||
## Related Documentation
|
||
|
||
- [hooks-module.md](./hooks-module.md) - usePreloadProgress, useOfflineMode, useStorageQuota
|
||
- [lib-module.md](./lib-module.md) - DownloadEventBus, StorageManager
|
||
- [config-module.md](./config-module.md) - OFFLINE_CONFIG, PRELOAD_CONFIG
|
||
- [types-module.md](./types-module.md) - Offline types
|
||
- [components-module.md](./components-module.md) - Offline components
|
||
|
||
---
|
||
|
||
## Adding New Contexts
|
||
|
||
To add a new React Context:
|
||
|
||
### Step 1: Create Context File
|
||
|
||
```typescript
|
||
// context/MyContext.tsx
|
||
import React, { createContext, useContext, useState, type ReactNode } from 'react';
|
||
|
||
interface MyContextValue {
|
||
value: string;
|
||
setValue: (v: string) => void;
|
||
}
|
||
|
||
const MyContext = createContext<MyContextValue | null>(null);
|
||
|
||
export function MyProvider({ children }: { children: ReactNode }) {
|
||
const [value, setValue] = useState('');
|
||
|
||
return (
|
||
<MyContext.Provider value={{ value, setValue }}>
|
||
{children}
|
||
</MyContext.Provider>
|
||
);
|
||
}
|
||
|
||
export function useMyContext(): MyContextValue {
|
||
const ctx = useContext(MyContext);
|
||
if (!ctx) {
|
||
throw new Error('useMyContext must be used within MyProvider');
|
||
}
|
||
return ctx;
|
||
}
|
||
|
||
export function useMyContextOptional(): MyContextValue | null {
|
||
return useContext(MyContext);
|
||
}
|
||
```
|
||
|
||
### Step 2: Add Provider to _app.tsx
|
||
|
||
```typescript
|
||
import { MyProvider } from '../context/MyContext';
|
||
|
||
function MyApp({ Component, pageProps }) {
|
||
return (
|
||
<Provider store={store}>
|
||
<DownloadProvider>
|
||
<MyProvider>
|
||
{/* ... */}
|
||
</MyProvider>
|
||
</DownloadProvider>
|
||
</Provider>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Step 3: Consume in Components
|
||
|
||
```typescript
|
||
import { useMyContext } from '@/context/MyContext';
|
||
|
||
function MyComponent() {
|
||
const { value, setValue } = useMyContext();
|
||
// ...
|
||
}
|
||
```
|