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.
58 lines
1.8 KiB
TypeScript
58 lines
1.8 KiB
TypeScript
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<UploadStatusSnapshot> {
|
|
const response = await api.get<UploadStatusSnapshot>(`/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`;
|
|
},
|
|
};
|