diff --git a/frontend/src/components/admin/AdminPhotoGrid.tsx b/frontend/src/components/admin/AdminPhotoGrid.tsx index f7c8d8f5..db1a676d 100644 --- a/frontend/src/components/admin/AdminPhotoGrid.tsx +++ b/frontend/src/components/admin/AdminPhotoGrid.tsx @@ -1,10 +1,12 @@ import React, { useState } from 'react'; -import { Check, Download, Trash2, Eye, EyeOff, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react'; +import { Check, Download, Trash2, Eye, EyeOff, Package, MessageSquare, Star, Video, FolderOpen, Cog, AlertTriangle, RefreshCw } from 'lucide-react'; import { toast } from 'react-toastify'; +import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { AdminPhoto } from '../../services/photos.service'; import { photosService } from '../../services/photos.service'; +import { uploadsService } from '../../services/uploads.service'; import { Button } from '../common'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; import { BulkCategoryModal } from './BulkCategoryModal'; @@ -32,6 +34,7 @@ export const AdminPhotoGrid: React.FC = ({ categories = [] }) => { const { t } = useTranslation(); + const queryClient = useQueryClient(); const [selectedPhotos, setSelectedPhotos] = useState>(new Set()); const [isSelectionMode, setIsSelectionMode] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -298,9 +301,42 @@ export const AdminPhotoGrid: React.FC = ({ )} - {/* Thumbnail */} + {/* Thumbnail (or processing placeholder for in-flight photos) */}
- {photo.thumbnail_url ? ( + {(photo as any).processing_status === 'pending' || + (photo as any).processing_status === 'processing' ? ( +
+ +

+ {t('admin.photos.processingStatus', 'Processing…')} +

+
+ ) : (photo as any).processing_status === 'failed' ? ( +
+ +

+ {t('admin.photos.processingFailed', 'Failed')} +

+ +
+ ) : photo.thumbnail_url ? ( = ({ eventId, onUploadCompl const [phase, setPhase] = useState({ kind: 'idle' }); const [selectedCategoryId, setSelectedCategoryId] = useState(null); const [replaceByName, setReplaceByName] = useState(false); + // Upload IDs returned from each chunk POST. The processing tracker + // hook merges status across all of them so the user sees one unified + // progress count even when the upload spans multiple HTTP requests. + const [uploadIds, setUploadIds] = useState([]); const fileInputRef = useRef(null); + + const { aggregate: processingAggregate } = useUploadProgress(uploadIds, { + enabled: phase.kind === 'processing' && uploadIds.length > 0, + }); // Fetch categories for this event const { data: categories = [] } = useQuery({ @@ -117,6 +126,7 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl setIsUploading(true); setUploadProgress(0); + setUploadIds([]); // For large uploads, chunk the files by both count AND size to prevent memory/network issues const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk @@ -210,6 +220,12 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl totalUploaded += (response.data?.successCount || chunk.length); totalReplaced += (response.data?.replacedCount || 0); + // Backend returns a per-request upload_id. Track it so the + // processing-status hook can poll/stream live progress. + if (response.data?.upload_id) { + const newId = response.data.upload_id as string; + setUploadIds((prev) => (prev.includes(newId) ? prev : [...prev, newId])); + } } catch (error: any) { console.error(`Error uploading chunk ${chunkIndex + 1}:`, error); failedFiles.push(...chunk.map(f => f.name)); @@ -219,41 +235,79 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl } } - // Clear selected files + // Bytes are all on the server. Clear the file picker so the + // user can queue another batch — but DON'T dismiss the upload + // UI yet; we'll watch the processing aggregate (useEffect below) + // to know when the backend has finished generating thumbnails + // and metadata. setSelectedFiles([]); if (fileInputRef.current) { fileInputRef.current.value = ''; } - // Show appropriate message if (totalReplaced > 0) { toast.info(t('upload.replacedFiles', { count: totalReplaced }) || `${totalReplaced} photo(s) replaced`); } - if (failedFiles.length === 0) { - toast.success(t('upload.uploadComplete') || `Successfully uploaded ${totalUploaded} files`); - } else { + if (failedFiles.length > 0) { toast.warning( - t('upload.someFilesFailed') || - `Uploaded ${totalUploaded} files. ${failedFiles.length} files failed.` + t('upload.someFilesFailed') || + `Transferred ${totalUploaded} files. ${failedFiles.length} files failed to transfer.` ); } - - // Call callback + + // Refresh the grid early so the user sees their photos appearing + // as the worker processes them. The processing-aggregate effect + // below will refresh again on completion. if (onUploadComplete) { onUploadComplete(); } + + // If the backend never returned an upload_id (e.g. only failures + // or pre-async-backend deployment), we have nothing to wait for — + // fall through to the finally cleanup which resets state. } catch (error: any) { console.error('Upload error:', error); toast.error(error.response?.data?.error || t('toast.uploadError')); - } finally { setIsUploading(false); setUploadProgress(0); setCurrentChunk(0); setTotalChunks(0); setPhase({ kind: 'idle' }); + setUploadIds([]); } }; + // When the background worker finishes processing every photo from + // this upload, dismiss the upload UI and surface the result. + useEffect(() => { + if (!isUploading) return; + if (uploadIds.length === 0) return; + if (!processingAggregate.isComplete) return; + + if (processingAggregate.failed > 0) { + toast.warning( + t('upload.processingFailed', { count: processingAggregate.failed }) || + `${processingAggregate.failed} photo(s) failed to process` + ); + } else { + toast.success( + t('upload.uploadComplete') || `Successfully uploaded ${processingAggregate.complete} photo(s)` + ); + } + + if (onUploadComplete) onUploadComplete(); + setIsUploading(false); + setUploadProgress(0); + setCurrentChunk(0); + setTotalChunks(0); + setPhase({ kind: 'idle' }); + setUploadIds([]); + // We intentionally only react to processingAggregate.isComplete / + // .failed — the rest of the deps either don't move during this + // effect's lifetime or are stable callbacks. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [processingAggregate.isComplete, processingAggregate.failed, isUploading]); + const formatFileSize = (bytes: number) => { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; @@ -398,9 +452,34 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl

{t('upload.processing')} - {phase.totalChunks > 1 && ` (${t('common.chunk')} ${phase.chunkIndex + 1}/${phase.totalChunks})`}

-

+ {processingAggregate.total > 0 && ( + <> +

+ {t('upload.processingProgress', { + complete: processingAggregate.complete + processingAggregate.failed, + total: processingAggregate.total, + })} +

+
+
+
+ + )} +

{t('upload.processingHint')}

diff --git a/frontend/src/hooks/useUploadProgress.ts b/frontend/src/hooks/useUploadProgress.ts new file mode 100644 index 00000000..90cd3408 --- /dev/null +++ b/frontend/src/hooks/useUploadProgress.ts @@ -0,0 +1,159 @@ +import { useEffect, useRef, useState } from 'react'; +import { + uploadsService, + type UploadStatusSnapshot, +} from '../services/uploads.service'; + +interface UseUploadProgressOptions { + /** + * If false, the hook does nothing (used to "pause" tracking when no + * upload is in progress). Default: true. + */ + enabled?: boolean; + /** + * Polling interval in ms (used always as a fallback, and as the + * primary channel when SSE is unavailable). Default: 1500. + */ + pollIntervalMs?: number; + /** + * If true, attempts an SSE upgrade for low-latency updates and + * falls back to polling when the stream errors. Default: true. + */ + preferStream?: boolean; +} + +/** + * Tracks an upload group's processing state. Returns a merged snapshot + * across all upload IDs the caller passes in (admin upload modal sends + * each chunk as its own upload_id; this hook merges their counters). + * + * The hook is resilient: it always polls in the background and uses + * SSE (when available and not disabled) as a faster supplementary + * channel. Either source landing on a terminal state stops the hook. + */ +export function useUploadProgress( + uploadIds: string[], + { enabled = true, pollIntervalMs = 1500, preferStream = true }: UseUploadProgressOptions = {} +) { + const [snapshots, setSnapshots] = useState>({}); + const [error, setError] = useState(null); + const eventSourcesRef = useRef>({}); + // Stable string key so we re-trigger the effect only when the actual + // set of IDs changes (parents may pass a new array each render). + const idsKey = uploadIds.join('|'); + + useEffect(() => { + if (!enabled || uploadIds.length === 0) { + return undefined; + } + + let cancelled = false; + const pollHandles: Record> = {}; + + const closeStream = (uploadId: string) => { + const es = eventSourcesRef.current[uploadId]; + if (es) { + es.close(); + delete eventSourcesRef.current[uploadId]; + } + }; + + const isTerminal = (snap: UploadStatusSnapshot | null) => + !!snap && snap.pending === 0 && snap.processing === 0; + + const merge = (uploadId: string, snap: UploadStatusSnapshot) => { + if (cancelled) return; + setSnapshots((prev) => ({ ...prev, [uploadId]: snap })); + }; + + const pollOnce = async (uploadId: string) => { + try { + const snap = await uploadsService.getStatus(uploadId); + merge(uploadId, snap); + if (!isTerminal(snap)) { + pollHandles[uploadId] = setTimeout(() => pollOnce(uploadId), pollIntervalMs); + } else { + closeStream(uploadId); + } + } catch (e) { + if (!cancelled) setError(e as Error); + // Retry polling on error after a longer interval — don't drop + // the group entirely just because one snapshot failed. + pollHandles[uploadId] = setTimeout(() => pollOnce(uploadId), pollIntervalMs * 4); + } + }; + + const tryStream = (uploadId: string) => { + if (typeof EventSource === 'undefined') return; + try { + const es = new EventSource(uploadsService.streamUrl(uploadId), { withCredentials: true }); + eventSourcesRef.current[uploadId] = es; + + es.onmessage = (event) => { + try { + const payload: UploadStatusSnapshot = JSON.parse(event.data); + merge(uploadId, payload); + if (isTerminal(payload)) { + closeStream(uploadId); + } + } catch (_) { + /* ignore malformed event */ + } + }; + + es.onerror = () => { + // Treat any error as a fatal stream failure; polling keeps + // running anyway and will pick up status. Avoids reconnect + // storms on broken proxies. + closeStream(uploadId); + }; + } catch (_) { + // EventSource construction failed — polling alone covers it. + } + }; + + for (const uploadId of uploadIds) { + pollOnce(uploadId); + if (preferStream) tryStream(uploadId); + } + + return () => { + cancelled = true; + for (const handle of Object.values(pollHandles)) clearTimeout(handle); + for (const uploadId of Object.keys(eventSourcesRef.current)) closeStream(uploadId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [idsKey, enabled, pollIntervalMs, preferStream]); + + // Aggregate counters across all tracked upload IDs. + const aggregate = (() => { + const totals = { total: 0, pending: 0, processing: 0, complete: 0, failed: 0 }; + const failedPhotos: { id: number; filename: string; error: string | null }[] = []; + let allReady = true; + for (const uploadId of uploadIds) { + const snap = snapshots[uploadId]; + if (!snap) { + allReady = false; + continue; + } + totals.total += snap.total; + totals.pending += snap.pending; + totals.processing += snap.processing; + totals.complete += snap.complete; + totals.failed += snap.failed; + for (const p of snap.photos) { + if (p.status === 'failed') { + failedPhotos.push({ id: p.id, filename: p.original_filename, error: p.error }); + } + } + } + const isComplete = allReady && totals.pending === 0 && totals.processing === 0 && totals.total > 0; + return { ...totals, failedPhotos, isComplete, isReady: allReady }; + })(); + + return { + snapshots, + aggregate, + error, + }; +} diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 445c73e7..644a1ecb 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -302,11 +302,22 @@ export const EventDetailsPage: React.FC = () => { logic: feedbackFilters.logic, }), [photoFilters, feedbackFilters]); - // Fetch photos (needed for both photos tab and hero photo selector) + // Fetch photos (needed for both photos tab and hero photo selector). + // While any photo is still in pending/processing state we poll every + // 2s so the admin grid auto-updates as the background worker drains + // the queue. Once everything is complete/failed the polling stops. const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({ queryKey: ['admin-event-photos', id, combinedPhotoFilters], queryFn: () => photosService.getEventPhotos(parseInt(id!), combinedPhotoFilters), enabled: !!id && (activeTab === 'photos' || isEditing), + refetchInterval: (query) => { + const data = query.state.data as AdminPhoto[] | undefined; + if (!Array.isArray(data)) return false; + const inFlight = data.some( + (p: any) => p.processing_status === 'pending' || p.processing_status === 'processing' + ); + return inFlight ? 2000 : false; + }, }); // Fetch filter summary for feedback filters diff --git a/frontend/src/services/uploads.service.ts b/frontend/src/services/uploads.service.ts new file mode 100644 index 00000000..a90d2faa --- /dev/null +++ b/frontend/src/services/uploads.service.ts @@ -0,0 +1,57 @@ +import { api } from '../config/api'; + +export type PhotoProcessingStatus = 'pending' | 'processing' | 'complete' | 'failed'; + +export interface UploadPhotoStatus { + id: number; + filename: string; + original_filename: string; + status: PhotoProcessingStatus; + error: string | null; +} + +export interface UploadStatusSnapshot { + upload_id: string; + event_id: number; + total: number; + pending: number; + processing: number; + complete: number; + failed: number; + photos: UploadPhotoStatus[]; +} + +export const uploadsService = { + /** + * One-shot snapshot of an upload group's processing state. Frontends + * poll this every 1.5s while any photo is still pending/processing. + */ + async getStatus(uploadId: string): Promise { + const response = await api.get(`/admin/uploads/${uploadId}/status`); + return response.data; + }, + + /** + * Retry a failed photo. Flips status back to 'pending' so the + * background worker picks it up again. + */ + async retryPhoto(photoId: number): Promise<{ id: number; status: PhotoProcessingStatus }> { + const response = await api.post<{ id: number; status: PhotoProcessingStatus }>( + `/admin/photos/${photoId}/retry` + ); + return response.data; + }, + + /** + * Build the SSE stream URL for an upload group. Caller is responsible + * for opening an EventSource and merging the JSON-payload events into + * their progress state. Falls back to polling getStatus() if the + * EventSource fails to open (proxy buffering, etc.). + */ + streamUrl(uploadId: string): string { + // EventSource doesn't send our auth headers, so we have to rely on + // the cookie-based admin session. (PicPeak's auth middleware reads + // cookies before falling back to Authorization headers.) + return `${api.defaults.baseURL || ''}/admin/uploads/${uploadId}/stream`; + }, +};