Merge pull request #363 from the-luap/feat/upload-redesign-and-auth-loop-fix

feat(upload): async photo processing + fix(auth): /auth/session symmetry (loop fix)
This commit is contained in:
Paul Nothaft
2026-05-02 22:59:03 +02:00
committed by GitHub
20 changed files with 2011 additions and 383 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}
+176 -34
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useMemo } from 'react';
import { Upload, X, Image, Loader2 } from 'lucide-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';
import { api } from '../../config/api';
@@ -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;
@@ -18,6 +19,15 @@ interface PhotoUploadProps {
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
// Upload phase machine. The user perceives "frozen" during 'processing'
// because the bytes are already on the server and we're waiting for
// thumbnail/EXIF/etc. work — the explicit phase + hint message kills
// that perception (#352 / contributor analysis on issue 357 review).
type UploadPhase =
| { kind: 'idle' }
| { kind: 'transferring'; chunkIndex: number; totalChunks: number; bytePct: number }
| { kind: 'processing'; chunkIndex: number; totalChunks: number; filesInChunk: number };
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
const { t } = useTranslation();
const [isUploading, setIsUploading] = useState(false);
@@ -25,9 +35,18 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const [uploadProgress, setUploadProgress] = useState(0);
const [currentChunk, setCurrentChunk] = useState(0);
const [totalChunks, setTotalChunks] = useState(0);
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({
@@ -107,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
@@ -144,11 +164,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
setCurrentChunk(chunkIndex + 1);
const chunk = chunks[chunkIndex];
const formData = new FormData();
chunk.forEach((file) => {
formData.append('photos', file);
});
if (selectedCategoryId) {
formData.append('category_id', selectedCategoryId.toString());
}
@@ -156,63 +176,138 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
formData.append('replace_by_name', 'true');
}
setPhase({
kind: 'transferring',
chunkIndex,
totalChunks: chunks.length,
bytePct: 0,
});
try {
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
// Calculate overall progress across all chunks
const chunkProgress = progressEvent.loaded / progressEvent.total;
const overallProgress = ((chunkIndex + chunkProgress) / chunks.length) * 100;
setUploadProgress(Math.round(overallProgress));
// Once bytes have all left the browser, the request is
// sitting in the backend processing pipeline. Flip to
// 'processing' so the UI explains the wait instead of
// looking frozen at the chunk's max progress.
if (chunkProgress >= 1) {
setPhase((prev) =>
prev.kind === 'transferring' && prev.chunkIndex === chunkIndex
? {
kind: 'processing',
chunkIndex,
totalChunks: chunks.length,
filesInChunk: chunk.length,
}
: prev
);
} else {
setPhase({
kind: 'transferring',
chunkIndex,
totalChunks: chunks.length,
bytePct: Math.round(chunkProgress * 100),
});
}
}
},
});
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));
// Continue with next chunk even if one fails
continue;
}
}
// 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';
@@ -344,26 +439,73 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
</Button>
</div>
{/* Progress Bar */}
{/* Progress display — two distinct phases. Bytes-on-wire ('transferring')
drives the determinate bar; the post-bytes wait ('processing') swaps
in an indeterminate spinner with an explanatory hint so users don't
assume the upload froze. */}
{isUploading && (
<div className="mt-4">
<div className="flex justify-between text-sm text-neutral-600 dark:text-neutral-400 mb-1">
<span>
{t('upload.uploading')}
{totalChunks > 1 && ` (${t('common.chunk')} ${currentChunk}/${totalChunks})`}
</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
{totalChunks > 1 && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('upload.uploadingChunks', { count: selectedFiles.length, total: totalChunks })}
</p>
{phase.kind === 'processing' ? (
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-4">
<div className="flex items-start gap-3">
<Cog className="w-5 h-5 text-amber-600 dark:text-amber-400 animate-spin shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
{t('upload.processing')}
</p>
{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>
</div>
</div>
) : (
<>
<div className="flex justify-between text-sm text-neutral-600 dark:text-neutral-400 mb-1">
<span>
{t('upload.transferring')}
{totalChunks > 1 && ` (${t('common.chunk')} ${currentChunk}/${totalChunks})`}
</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
{totalChunks > 1 && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('upload.uploadingChunks', { count: selectedFiles.length, total: totalChunks })}
</p>
)}
</>
)}
</div>
)}
@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react';
import { Upload, X, CheckCircle } from 'lucide-react';
import { Upload, X, CheckCircle, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Button } from '../common';
@@ -24,6 +24,10 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
// Per-file processing state — flips to true once axios reports
// bytes-on-wire for that file, so the UI can show "Processing…"
// instead of a static 100% bar while the backend works.
const [processingFiles, setProcessingFiles] = useState<{ [key: string]: boolean }>({});
const { data: publicSettings } = usePublicSettings();
@@ -87,9 +91,18 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
...prev,
[file.name]: progress,
}));
if (progress >= 100) {
setProcessingFiles(prev => ({ ...prev, [file.name]: true }));
}
}
},
});
// Request resolved → file fully processed by backend.
setProcessingFiles(prev => {
const next = { ...prev };
delete next[file.name];
return next;
});
successCount++;
} catch (error: any) {
// Upload error handled - user notified via UI
@@ -185,7 +198,13 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
</div>
{uploadProgress[file.name] !== undefined ? (
<div className="flex items-center gap-2">
{uploadProgress[file.name] === 100 ? (
{processingFiles[file.name] ? (
// Bytes are on the server; the request hasn't
// resolved yet because the backend is still
// generating thumbnails / reading EXIF. Show
// a spinner so it doesn't look stuck at 100%.
<Loader2 className="w-5 h-5 text-amber-600 animate-spin" />
) : uploadProgress[file.name] === 100 ? (
<CheckCircle className="w-5 h-5 text-green-600" />
) : (
<div className="w-20">
+159
View File
@@ -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,
};
}
+6
View File
@@ -131,6 +131,12 @@
"unsupportedFiles": "Einige Dateien wurden übersprungen, da das Format nicht unterstützt wird (JPEG/PNG/WebP/MP4/MOV/WEBM verwenden).",
"selectedFiles": "Ausgewählte Dateien",
"uploading": "Wird hochgeladen...",
"transferring": "Übertragung",
"processing": "Fotos werden verarbeitet...",
"processingHint": "Dateien sind hochgeladen. PicPeak erstellt jetzt Thumbnails und liest Metadaten. Sie können diese Seite verlassen — die Verarbeitung läuft im Hintergrund weiter.",
"processingProgress": "{{complete}} von {{total}} fertig",
"processingFailed": "{{count}} Foto(s) konnten nicht verarbeitet werden",
"retryFailed": "Fehlgeschlagene erneut versuchen",
"uploadComplete": "Upload abgeschlossen!",
"uploadFailed": "Upload fehlgeschlagen",
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
+6
View File
@@ -131,6 +131,12 @@
"unsupportedFiles": "Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).",
"selectedFiles": "Selected files",
"uploading": "Uploading...",
"transferring": "Transferring",
"processing": "Processing photos...",
"processingHint": "Files are uploaded. PicPeak is now generating thumbnails and reading metadata. You can leave this page — work continues in the background.",
"processingProgress": "{{complete}} of {{total}} done",
"processingFailed": "{{count}} photo(s) failed to process",
"retryFailed": "Retry failed",
"uploadComplete": "Upload complete!",
"uploadFailed": "Upload failed",
"someFilesFailed": "Some files failed to upload",
+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
+57
View File
@@ -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`;
},
};