diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a1710c..1f39dd8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import FacesMaintenance from './pages/FacesMaintenance' import ApproveIdentified from './pages/ApproveIdentified' import ManageUsers from './pages/ManageUsers' import ReportedPhotos from './pages/ReportedPhotos' +import PendingPhotos from './pages/PendingPhotos' import Settings from './pages/Settings' import Help from './pages/Help' import Layout from './components/Layout' @@ -98,6 +99,14 @@ function AppRoutes() { } /> + + + + } + /> } /> } /> diff --git a/frontend/src/api/pendingPhotos.ts b/frontend/src/api/pendingPhotos.ts new file mode 100644 index 0000000..5015b17 --- /dev/null +++ b/frontend/src/api/pendingPhotos.ts @@ -0,0 +1,76 @@ +import apiClient from './client' + +export interface PendingPhotoResponse { + id: number + user_id: number + user_name: string | null + user_email: string | null + filename: string + original_filename: string + file_path: string + file_size: number + mime_type: string + status: string + submitted_at: string + reviewed_at: string | null + reviewed_by: number | null + rejection_reason: string | null +} + +export interface PendingPhotosListResponse { + items: PendingPhotoResponse[] + total: number +} + +export interface ReviewDecision { + id: number + decision: 'approve' | 'reject' + rejection_reason?: string | null +} + +export interface ReviewRequest { + decisions: ReviewDecision[] +} + +export interface ReviewResponse { + approved: number + rejected: number + errors: string[] +} + +export const pendingPhotosApi = { + listPendingPhotos: async (statusFilter?: string): Promise => { + const { data } = await apiClient.get( + '/api/v1/pending-photos', + { + params: statusFilter ? { status_filter: statusFilter } : undefined, + } + ) + return data + }, + + getPendingPhotoImage: (photoId: number): string => { + return `${apiClient.defaults.baseURL}/api/v1/pending-photos/${photoId}/image` + }, + + getPendingPhotoImageBlob: async (photoId: number): Promise => { + // Fetch image as blob with authentication + const response = await apiClient.get( + `/api/v1/pending-photos/${photoId}/image`, + { + responseType: 'blob', + } + ) + // Create object URL from blob + return URL.createObjectURL(response.data) + }, + + reviewPendingPhotos: async (request: ReviewRequest): Promise => { + const { data } = await apiClient.post( + '/api/v1/pending-photos/review', + request + ) + return data + }, +} + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index f92aef2..c28cd7d 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -17,6 +17,7 @@ export default function Layout() { { path: '/approve-identified', label: 'Approve identified', icon: '✅', adminOnly: true }, { path: '/manage-users', label: 'Manage users', icon: '👥', adminOnly: true }, { path: '/reported-photos', label: 'Reported photos', icon: '🚩', adminOnly: true }, + { path: '/pending-photos', label: 'Manage User Uploaded Photos', icon: '📤', adminOnly: true }, { path: '/settings', label: 'Settings', icon: '⚙️', adminOnly: false }, { path: '/help', label: 'Help', icon: '📚', adminOnly: false }, ] diff --git a/frontend/src/pages/PendingPhotos.tsx b/frontend/src/pages/PendingPhotos.tsx new file mode 100644 index 0000000..4899616 --- /dev/null +++ b/frontend/src/pages/PendingPhotos.tsx @@ -0,0 +1,562 @@ +import { useEffect, useState, useCallback, useRef } from 'react' +import { + pendingPhotosApi, + PendingPhotoResponse, + ReviewDecision, +} from '../api/pendingPhotos' +import { apiClient } from '../api/client' + +export default function PendingPhotos() { + const [pendingPhotos, setPendingPhotos] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [decisions, setDecisions] = useState>({}) + const [rejectionReasons, setRejectionReasons] = useState>({}) + const [bulkRejectionReason, setBulkRejectionReason] = useState('') + const [submitting, setSubmitting] = useState(false) + const [statusFilter, setStatusFilter] = useState('pending') + const [imageUrls, setImageUrls] = useState>({}) + const imageUrlsRef = useRef>({}) + + const loadPendingPhotos = useCallback(async () => { + setLoading(true) + setError(null) + try { + const response = await pendingPhotosApi.listPendingPhotos( + statusFilter || undefined + ) + setPendingPhotos(response.items) + + // Clear decisions when loading different status + setDecisions({}) + setRejectionReasons({}) + + // Load images as blobs with authentication + const newImageUrls: Record = {} + for (const photo of response.items) { + try { + const blobUrl = await pendingPhotosApi.getPendingPhotoImageBlob(photo.id) + newImageUrls[photo.id] = blobUrl + } catch (err) { + console.error(`Failed to load image for photo ${photo.id}:`, err) + } + } + setImageUrls(newImageUrls) + imageUrlsRef.current = newImageUrls + } catch (err: any) { + setError(err.response?.data?.detail || err.message || 'Failed to load pending photos') + console.error('Error loading pending photos:', err) + } finally { + setLoading(false) + } + }, [statusFilter]) + + // Cleanup blob URLs on unmount + useEffect(() => { + return () => { + Object.values(imageUrlsRef.current).forEach((url) => { + URL.revokeObjectURL(url) + }) + } + }, []) + + useEffect(() => { + loadPendingPhotos() + }, [loadPendingPhotos]) + + const formatDate = (dateString: string | null | undefined): string => { + if (!dateString) return '-' + try { + const date = new Date(dateString) + return date.toLocaleString() + } catch { + return dateString + } + } + + const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] + } + + const handleDecisionChange = (id: number, decision: 'approve' | 'reject') => { + const currentDecision = decisions[id] + const isUnselecting = currentDecision === decision + + setDecisions((prev) => { + // If clicking the same option, unselect it + if (prev[id] === decision) { + const updated = { ...prev } + delete updated[id] + return updated + } + // Otherwise, set the new decision (this automatically unchecks the other checkbox) + return { + ...prev, + [id]: decision, + } + }) + + // Handle rejection reasons + if (isUnselecting) { + // Unselecting - clear rejection reason + setRejectionReasons((prev) => { + const updated = { ...prev } + delete updated[id] + return updated + }) + } else if (decision === 'approve') { + // Switching to approve - clear rejection reason + setRejectionReasons((prev) => { + const updated = { ...prev } + delete updated[id] + return updated + }) + } else if (decision === 'reject' && bulkRejectionReason.trim()) { + // Switching to reject - apply bulk rejection reason if set + setRejectionReasons((prev) => ({ + ...prev, + [id]: bulkRejectionReason, + })) + } + } + + const handleRejectionReasonChange = (id: number, reason: string) => { + setRejectionReasons((prev) => ({ + ...prev, + [id]: reason, + })) + } + + const handleSelectAllApprove = () => { + const pendingPhotoIds = pendingPhotos + .filter((photo) => photo.status === 'pending') + .map((photo) => photo.id) + + const newDecisions: Record = {} + pendingPhotoIds.forEach((id) => { + newDecisions[id] = 'approve' + }) + + setDecisions((prev) => ({ + ...prev, + ...newDecisions, + })) + + // Clear all rejection reasons and bulk rejection reason since we're approving + setRejectionReasons({}) + setBulkRejectionReason('') + } + + const handleSelectAllReject = () => { + const pendingPhotoIds = pendingPhotos + .filter((photo) => photo.status === 'pending') + .map((photo) => photo.id) + + const newDecisions: Record = {} + pendingPhotoIds.forEach((id) => { + newDecisions[id] = 'reject' + }) + + setDecisions((prev) => ({ + ...prev, + ...newDecisions, + })) + + // Apply bulk rejection reason if set + if (bulkRejectionReason.trim()) { + const newRejectionReasons: Record = {} + pendingPhotoIds.forEach((id) => { + newRejectionReasons[id] = bulkRejectionReason + }) + setRejectionReasons((prev) => ({ + ...prev, + ...newRejectionReasons, + })) + } + } + + const handleBulkRejectionReasonChange = (reason: string) => { + setBulkRejectionReason(reason) + + // Apply to all currently rejected photos + const rejectedPhotoIds = Object.entries(decisions) + .filter(([id, decision]) => decision === 'reject') + .map(([id]) => parseInt(id)) + + if (rejectedPhotoIds.length > 0) { + const newRejectionReasons: Record = {} + rejectedPhotoIds.forEach((id) => { + newRejectionReasons[id] = reason + }) + setRejectionReasons((prev) => ({ + ...prev, + ...newRejectionReasons, + })) + } + } + + const handleSubmit = async () => { + // Get all decisions that have been made for pending items + const decisionsList: ReviewDecision[] = Object.entries(decisions) + .filter(([id, decision]) => { + const photo = pendingPhotos.find((p) => p.id === parseInt(id)) + return decision !== null && photo && photo.status === 'pending' + }) + .map(([id, decision]) => ({ + id: parseInt(id), + decision: decision!, + rejection_reason: decision === 'reject' ? (rejectionReasons[parseInt(id)] || null) : null, + })) + + if (decisionsList.length === 0) { + alert('Please select Approve or Reject for at least one pending photo.') + return + } + + // Show confirmation + const approveCount = decisionsList.filter((d) => d.decision === 'approve').length + const rejectCount = decisionsList.filter((d) => d.decision === 'reject').length + const confirmMessage = `Submit ${decisionsList.length} decision(s)?\n\nThis will approve ${approveCount} photo(s) and reject ${rejectCount} photo(s).` + + if (!confirm(confirmMessage)) { + return + } + + setSubmitting(true) + try { + const response = await pendingPhotosApi.reviewPendingPhotos({ + decisions: decisionsList, + }) + + const message = [ + `✅ Approved: ${response.approved}`, + `❌ Rejected: ${response.rejected}`, + response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : '', + ] + .filter(Boolean) + .join('\n') + + alert(message) + + if (response.errors.length > 0) { + console.error('Errors:', response.errors) + } + + // Reload the list to show updated status + await loadPendingPhotos() + // Clear decisions and reasons + setDecisions({}) + setRejectionReasons({}) + } catch (err: any) { + const errorMessage = + err.response?.data?.detail || err.message || 'Failed to submit decisions' + alert(`Error: ${errorMessage}`) + console.error('Error submitting decisions:', err) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+

Manage User Uploaded Photos

+ + {loading && ( +
+

Loading pending photos...

+
+ )} + + {error && ( +
+

Error loading data

+

{error}

+ +
+ )} + + {!loading && !error && ( + <> +
+
+
+
+ Total photos: {pendingPhotos.length} +
+ +
+
+ {pendingPhotos.filter((p) => p.status === 'pending').length > 0 && ( + <> + + + + )} + +
+
+ {Object.values(decisions).some((d) => d === 'reject') && ( +
+ +