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
+12 -1
View File
@@ -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