feat(upload): async photo processing — frontend (PR-B part 2)

Live processing-state UI that complements the backend async pipeline.
Modal stays open through the processing phase and surfaces real
progress (X of N photos processed); the admin grid renders placeholder
cards for in-flight photos and auto-refreshes via polling until the
queue drains.

services/uploads.service.ts (new)
  - getStatus(uploadId)        — JSON snapshot from /admin/uploads/:id/status
  - retryPhoto(photoId)        — POST /admin/photos/:id/retry
  - streamUrl(uploadId)        — SSE upgrade URL

hooks/useUploadProgress.ts (new)
  - Tracks N concurrent upload IDs (one per chunk POST) and merges
    counters into a single aggregate.
  - Always polls every 1.5s; opportunistic SSE upgrade on top of that.
    SSE failure (proxy buffering, etc.) silently downgrades to polling
    only — no reconnect storms.
  - Auto-stops both channels when every tracked group is in a terminal
    (complete/failed) state.

components/admin/PhotoUpload.tsx
  - Captures upload_id from each chunk's 202 response, feeds them into
    useUploadProgress.
  - Phase machine extended: stays in 'processing' until the worker
    drains the queue (not just until bytes-on-wire). Progress UI shows
    real "X of N done" with a determinate bar fed by the aggregate.
  - "You can leave this page" hint kept — closing the modal is now
    actually safe, work continues server-side.
  - Side-effect refactor: invokes onUploadComplete twice — once early
    so the user sees photos appearing immediately, once on terminal
    so the parent grid sees final state.

components/admin/AdminPhotoGrid.tsx
  - Photos with processing_status pending/processing render an amber
    placeholder card with a spinning Cog instead of the missing
    thumbnail.
  - Photos with status='failed' render a red card with the error message
    and a "Retry" button that POSTs /admin/photos/:id/retry.

pages/admin/EventDetailsPage.tsx
  - Photo list query gains refetchInterval that polls every 2s while
    any photo is non-terminal, then stops. Keeps the grid auto-fresh
    during ongoing processing.
This commit is contained in:
Paul Nothaft
2026-05-02 22:56:04 +02:00
parent 851744c3c4
commit 3b827b80d5
5 changed files with 359 additions and 17 deletions
@@ -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<AdminPhotoGridProps> = ({
categories = []
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@@ -298,9 +301,42 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</div>
)}
{/* Thumbnail */}
{/* Thumbnail (or processing placeholder for in-flight photos) */}
<div className="aspect-square">
{photo.thumbnail_url ? (
{(photo as any).processing_status === 'pending' ||
(photo as any).processing_status === 'processing' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 gap-1 px-2 text-center">
<Cog className="w-7 h-7 animate-spin" />
<p className="text-[10px] font-medium leading-tight">
{t('admin.photos.processingStatus', 'Processing…')}
</p>
</div>
) : (photo as any).processing_status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 gap-1 px-2 text-center">
<AlertTriangle className="w-7 h-7" />
<p className="text-[10px] font-medium leading-tight">
{t('admin.photos.processingFailed', 'Failed')}
</p>
<button
onClick={async (e) => {
e.stopPropagation();
try {
await uploadsService.retryPhoto(photo.id);
toast.success(t('admin.photos.retryQueued', 'Retry queued'));
// Refetch grid via React Query so the placeholder
// updates without a full reload.
queryClient.invalidateQueries({ queryKey: ['admin-event-photos'] });
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Retry failed');
}
}}
className="mt-1 px-2 py-0.5 rounded bg-red-200 dark:bg-red-800 text-[10px] inline-flex items-center gap-1"
>
<RefreshCw className="w-2.5 h-2.5" />
{t('upload.retryFailed', 'Retry')}
</button>
</div>
) : photo.thumbnail_url ? (
<AdminAuthenticatedImage
src={photo.thumbnail_url}
alt={photo.filename}
+92 -13
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef, useMemo } from 'react';
import React, { useState, useRef, useMemo, useEffect } from 'react';
import { Upload, X, Image, Loader2, Cog } from 'lucide-react';
import { Button } from '../common';
import { clsx } from 'clsx';
@@ -9,6 +9,7 @@ import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
import { useUploadProgress } from '../../hooks/useUploadProgress';
interface PhotoUploadProps {
eventId: number;
@@ -37,7 +38,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const [phase, setPhase] = useState<UploadPhase>({ kind: 'idle' });
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(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<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(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<PhotoUploadProps> = ({ 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<PhotoUploadProps> = ({ 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<PhotoUploadProps> = ({ 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<PhotoUploadProps> = ({ eventId, onUploadCompl
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
{t('upload.processing')}
{phase.totalChunks > 1 && ` (${t('common.chunk')} ${phase.chunkIndex + 1}/${phase.totalChunks})`}
</p>
<p className="text-xs text-amber-800 dark:text-amber-200 mt-1">
{processingAggregate.total > 0 && (
<>
<p className="text-xs text-amber-900 dark:text-amber-100 font-medium mt-2">
{t('upload.processingProgress', {
complete: processingAggregate.complete + processingAggregate.failed,
total: processingAggregate.total,
})}
</p>
<div className="w-full bg-amber-100 dark:bg-amber-900/40 rounded-full h-2 mt-1">
<div
className="bg-amber-600 dark:bg-amber-500 h-2 rounded-full transition-all duration-300"
style={{
width: `${
processingAggregate.total === 0
? 0
: Math.round(
((processingAggregate.complete + processingAggregate.failed) /
processingAggregate.total) *
100
)
}%`,
}}
/>
</div>
</>
)}
<p className="text-xs text-amber-800 dark:text-amber-200 mt-2">
{t('upload.processingHint')}
</p>
</div>