From e6c66e564e956718c416dacdf56c1a851e56fbf8 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Fri, 21 Nov 2025 11:00:38 -0500 Subject: [PATCH] feat: Add Pending Photos management with API integration and UI updates This commit introduces a new Pending Photos feature, allowing admins to manage user-uploaded photos awaiting review. A dedicated PendingPhotos page has been created in the frontend, which fetches and displays pending photos with options to approve or reject them. The backend has been updated with new API endpoints for listing and reviewing pending photos, ensuring seamless integration with the frontend. The Layout component has been modified to include navigation to the new Pending Photos page, enhancing the overall user experience. Documentation has been updated to reflect these changes. --- frontend/src/App.tsx | 9 + frontend/src/api/pendingPhotos.ts | 76 ++++ frontend/src/components/Layout.tsx | 1 + frontend/src/pages/PendingPhotos.tsx | 562 +++++++++++++++++++++++++++ src/web/api/pending_photos.py | 396 +++++++++++++++++++ src/web/app.py | 2 + 6 files changed, 1046 insertions(+) create mode 100644 frontend/src/api/pendingPhotos.ts create mode 100644 frontend/src/pages/PendingPhotos.tsx create mode 100644 src/web/api/pending_photos.py 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') && ( +
+ +