# 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([]); // 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(() => ({ 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 ( {children} ); } ``` ### 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 (
{isDownloading && Downloading... {totalProgress}%}
    {jobs.map(job => (
  • {job.filename}: {job.progress}%
  • ))}
); } ``` ### 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 Downloading...; } 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 ( {getLayout( <> {/* Meta tags, favicon, PWA manifest */} )} ); } export default appWithTranslation(MyApp); ``` ### Provider Hierarchy ``` ← Redux ← React Context (download progress) {getLayout( ← Per-page layout (LayoutAuthenticated/LayoutGuest) <> ← Meta tags, PWA config ← Page component ← Onboarding tour )} ``` --- ## Related: DownloadEventBus The context subscribes to events from `DownloadEventBus`, a browser-native event emitter: ```typescript // lib/offline/DownloadEventBus.ts class DownloadEventBusClass { private listeners: Map>> = new Map(); on(event: K, callback: EventCallback): () => void; off(event: K, callback: EventCallback): void; emit(event: K, data: EventMap[K]): void; once(event: K, callback: EventCallback): () => 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([]); // 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 (
{/* Header */}
Downloads {isActive && {activeCount} active} {completedCount > 0 && ( )}
{/* Overall progress */} {isActive && (
Overall progress {totalProgress}%
)} {/* Job list */}
{visibleJobs.map((job) => (
{/* Status icon */} {job.status === 'downloading' && ( )} {job.status === 'completed' && ( )} {job.status === 'error' && ( )} {/* Info */}
{job.filename}
{job.status === 'downloading' && (
)}
{/* Close button */}
))} {hiddenCount > 0 && (
+{hiddenCount} more items
)}
); } ``` ### 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 (
{showLabel && ( {label} )}
); } ``` ### 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 (
{isDownloaded && ( )}
); } ``` ### 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
; } 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 (
Storage
{showDetails && ( {formatSize(usage)} / {formatSize(quota)} )}
{showDetails && (
{isCritical ? 'Storage critically low' : isWarning ? 'Storage running low' : `${Math.round(100 - percentUsed)}% available`} {!isPersisted && ( )} {isPersisted && ( Persistent storage enabled )}
)}
); } ``` --- ## 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 // Clear specific job ``` --- ## 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(null); export function MyProvider({ children }: { children: ReactNode }) { const [value, setValue] = useState(''); return ( {children} ); } 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 ( {/* ... */} ); } ``` ### Step 3: Consume in Components ```typescript import { useMyContext } from '@/context/MyContext'; function MyComponent() { const { value, setValue } = useMyContext(); // ... } ```