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:
@@ -1,10 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
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 { toast } from 'react-toastify';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { AdminPhoto } from '../../services/photos.service';
|
import { AdminPhoto } from '../../services/photos.service';
|
||||||
import { photosService } from '../../services/photos.service';
|
import { photosService } from '../../services/photos.service';
|
||||||
|
import { uploadsService } from '../../services/uploads.service';
|
||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||||
import { BulkCategoryModal } from './BulkCategoryModal';
|
import { BulkCategoryModal } from './BulkCategoryModal';
|
||||||
@@ -32,6 +34,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
categories = []
|
categories = []
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
@@ -298,9 +301,42 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail (or processing placeholder for in-flight photos) */}
|
||||||
<div className="aspect-square">
|
<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
|
<AdminAuthenticatedImage
|
||||||
src={photo.thumbnail_url}
|
src={photo.thumbnail_url}
|
||||||
alt={photo.filename}
|
alt={photo.filename}
|
||||||
|
|||||||
@@ -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 { Upload, X, Image, Loader2, Cog } from 'lucide-react';
|
||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
@@ -9,6 +9,7 @@ import { categoriesService } from '../../services/categories.service';
|
|||||||
import { settingsService } from '../../services/settings.service';
|
import { settingsService } from '../../services/settings.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
|
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
|
||||||
|
import { useUploadProgress } from '../../hooks/useUploadProgress';
|
||||||
|
|
||||||
interface PhotoUploadProps {
|
interface PhotoUploadProps {
|
||||||
eventId: number;
|
eventId: number;
|
||||||
@@ -37,8 +38,16 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
const [phase, setPhase] = useState<UploadPhase>({ kind: 'idle' });
|
const [phase, setPhase] = useState<UploadPhase>({ kind: 'idle' });
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||||
const [replaceByName, setReplaceByName] = useState(false);
|
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { aggregate: processingAggregate } = useUploadProgress(uploadIds, {
|
||||||
|
enabled: phase.kind === 'processing' && uploadIds.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch categories for this event
|
// Fetch categories for this event
|
||||||
const { data: categories = [] } = useQuery({
|
const { data: categories = [] } = useQuery({
|
||||||
queryKey: ['event-categories', eventId],
|
queryKey: ['event-categories', eventId],
|
||||||
@@ -117,6 +126,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
|
setUploadIds([]);
|
||||||
|
|
||||||
// For large uploads, chunk the files by both count AND size to prevent memory/network issues
|
// 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
|
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);
|
totalUploaded += (response.data?.successCount || chunk.length);
|
||||||
totalReplaced += (response.data?.replacedCount || 0);
|
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) {
|
} catch (error: any) {
|
||||||
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
|
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
|
||||||
failedFiles.push(...chunk.map(f => f.name));
|
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([]);
|
setSelectedFiles([]);
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = '';
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show appropriate message
|
|
||||||
if (totalReplaced > 0) {
|
if (totalReplaced > 0) {
|
||||||
toast.info(t('upload.replacedFiles', { count: totalReplaced }) || `${totalReplaced} photo(s) replaced`);
|
toast.info(t('upload.replacedFiles', { count: totalReplaced }) || `${totalReplaced} photo(s) replaced`);
|
||||||
}
|
}
|
||||||
if (failedFiles.length === 0) {
|
if (failedFiles.length > 0) {
|
||||||
toast.success(t('upload.uploadComplete') || `Successfully uploaded ${totalUploaded} files`);
|
|
||||||
} else {
|
|
||||||
toast.warning(
|
toast.warning(
|
||||||
t('upload.someFilesFailed') ||
|
t('upload.someFilesFailed') ||
|
||||||
`Uploaded ${totalUploaded} files. ${failedFiles.length} files failed.`
|
`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) {
|
if (onUploadComplete) {
|
||||||
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) {
|
} catch (error: any) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
toast.error(error.response?.data?.error || t('toast.uploadError'));
|
toast.error(error.response?.data?.error || t('toast.uploadError'));
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setCurrentChunk(0);
|
setCurrentChunk(0);
|
||||||
setTotalChunks(0);
|
setTotalChunks(0);
|
||||||
setPhase({ kind: 'idle' });
|
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) => {
|
const formatFileSize = (bytes: number) => {
|
||||||
if (bytes < 1024) return bytes + ' B';
|
if (bytes < 1024) return bytes + ' B';
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
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">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||||
{t('upload.processing')}
|
{t('upload.processing')}
|
||||||
{phase.totalChunks > 1 && ` (${t('common.chunk')} ${phase.chunkIndex + 1}/${phase.totalChunks})`}
|
|
||||||
</p>
|
</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')}
|
{t('upload.processingHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<Record<string, UploadStatusSnapshot | null>>({});
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const eventSourcesRef = useRef<Record<string, EventSource>>({});
|
||||||
|
// 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<string, ReturnType<typeof setTimeout>> = {};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -302,11 +302,22 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
logic: feedbackFilters.logic,
|
logic: feedbackFilters.logic,
|
||||||
}), [photoFilters, feedbackFilters]);
|
}), [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({
|
const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({
|
||||||
queryKey: ['admin-event-photos', id, combinedPhotoFilters],
|
queryKey: ['admin-event-photos', id, combinedPhotoFilters],
|
||||||
queryFn: () => photosService.getEventPhotos(parseInt(id!), combinedPhotoFilters),
|
queryFn: () => photosService.getEventPhotos(parseInt(id!), combinedPhotoFilters),
|
||||||
enabled: !!id && (activeTab === 'photos' || isEditing),
|
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
|
// Fetch filter summary for feedback filters
|
||||||
|
|||||||
@@ -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<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`;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user