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.
This commit is contained in:
tanyar09
2025-11-21 11:00:38 -05:00
parent 2c3b2d7a08
commit e6c66e564e
6 changed files with 1046 additions and 0 deletions
+9
View File
@@ -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() {
</AdminRoute>
}
/>
<Route
path="pending-photos"
element={
<AdminRoute>
<PendingPhotos />
</AdminRoute>
}
/>
<Route path="settings" element={<Settings />} />
<Route path="help" element={<Help />} />
</Route>
+76
View File
@@ -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<PendingPhotosListResponse> => {
const { data } = await apiClient.get<PendingPhotosListResponse>(
'/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<string> => {
// 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<ReviewResponse> => {
const { data } = await apiClient.post<ReviewResponse>(
'/api/v1/pending-photos/review',
request
)
return data
},
}
+1
View File
@@ -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 },
]
+562
View File
@@ -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<PendingPhotoResponse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [decisions, setDecisions] = useState<Record<number, 'approve' | 'reject' | null>>({})
const [rejectionReasons, setRejectionReasons] = useState<Record<number, string>>({})
const [bulkRejectionReason, setBulkRejectionReason] = useState<string>('')
const [submitting, setSubmitting] = useState(false)
const [statusFilter, setStatusFilter] = useState<string>('pending')
const [imageUrls, setImageUrls] = useState<Record<number, string>>({})
const imageUrlsRef = useRef<Record<number, string>>({})
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<number, string> = {}
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<number, 'approve'> = {}
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<number, 'reject'> = {}
pendingPhotoIds.forEach((id) => {
newDecisions[id] = 'reject'
})
setDecisions((prev) => ({
...prev,
...newDecisions,
}))
// Apply bulk rejection reason if set
if (bulkRejectionReason.trim()) {
const newRejectionReasons: Record<number, string> = {}
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<number, string> = {}
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 (
<div>
<div className="bg-white rounded-lg shadow p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Manage User Uploaded Photos</h1>
{loading && (
<div className="text-center py-8">
<p className="text-gray-600">Loading pending photos...</p>
</div>
)}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
<p className="font-semibold">Error loading data</p>
<p className="text-sm mt-1">{error}</p>
<button
onClick={loadPendingPhotos}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Retry
</button>
</div>
)}
{!loading && !error && (
<>
<div className="mb-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-gray-600">
Total photos: <span className="font-semibold">{pendingPhotos.length}</span>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div>
<div className="flex items-center gap-2">
{pendingPhotos.filter((p) => p.status === 'pending').length > 0 && (
<>
<button
onClick={handleSelectAllApprove}
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Approve
</button>
<button
onClick={handleSelectAllReject}
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Reject
</button>
</>
)}
<button
onClick={handleSubmit}
disabled={
submitting ||
Object.values(decisions).filter((d) => d !== null).length === 0
}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
>
{submitting ? 'Submitting...' : 'Submit Decisions'}
</button>
</div>
</div>
{Object.values(decisions).some((d) => d === 'reject') && (
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700 font-medium whitespace-nowrap">
Bulk Rejection Reason:
</label>
<textarea
value={bulkRejectionReason}
onChange={(e) => handleBulkRejectionReasonChange(e.target.value)}
placeholder="Enter rejection reason to apply to all rejected photos..."
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md resize-none focus:ring-blue-500 focus:border-blue-500"
rows={2}
/>
</div>
)}
</div>
{pendingPhotos.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p>No pending photos found.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Photo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Uploaded By
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
File Info
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Submitted At
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Decision
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rejection Reason
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{pendingPhotos.map((photo) => {
const isPending = photo.status === 'pending'
const isApproved = photo.status === 'approved'
const isRejected = photo.status === 'rejected'
const canMakeDecision = isPending
return (
<tr
key={photo.id}
className={`hover:bg-gray-50 ${
isApproved || isRejected ? 'opacity-60 bg-gray-50' : ''
}`}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={async () => {
// For full-size view, fetch as blob and open in new tab
try {
const blobUrl = imageUrls[photo.id] || await pendingPhotosApi.getPendingPhotoImageBlob(photo.id)
// Create a new window with the blob URL
const newWindow = window.open()
if (newWindow) {
newWindow.location.href = blobUrl
}
} catch (err) {
console.error('Failed to open full-size image:', err)
alert('Failed to load full-size image')
}
}}
title="Click to open full photo"
>
{imageUrls[photo.id] ? (
<img
src={imageUrls[photo.id]}
alt={photo.original_filename}
className="w-24 h-24 object-cover rounded border border-gray-300"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className =
'text-gray-400 text-xs error-fallback'
fallback.textContent = 'Image not found'
parent.appendChild(fallback)
}
}}
/>
) : (
<div className="w-24 h-24 bg-gray-200 rounded border border-gray-300 flex items-center justify-center text-xs text-gray-400">
Loading...
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{photo.user_name || 'Unknown'}
</div>
<div className="text-sm text-gray-500">
{photo.user_email || '-'}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">
{photo.original_filename}
</div>
<div className="text-sm text-gray-500">
{formatFileSize(photo.file_size)} • {photo.mime_type}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDate(photo.submitted_at)}
</div>
{photo.reviewed_at && (
<div className="text-xs text-gray-400 mt-1">
Reviewed: {formatDate(photo.reviewed_at)}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
photo.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: photo.status === 'approved'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{photo.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{canMakeDecision ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decisions[photo.id] === 'approve'}
onChange={(e) => {
if (e.target.checked) {
handleDecisionChange(photo.id, 'approve')
} else {
// Unchecking - remove decision
handleDecisionChange(photo.id, 'approve')
}
}}
className="w-4 h-4 text-green-600 focus:ring-green-500 rounded"
/>
<span className="text-sm text-gray-700">Approve</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decisions[photo.id] === 'reject'}
onChange={(e) => {
if (e.target.checked) {
handleDecisionChange(photo.id, 'reject')
} else {
// Unchecking - remove decision
handleDecisionChange(photo.id, 'reject')
}
}}
className="w-4 h-4 text-red-600 focus:ring-red-500 rounded"
/>
<span className="text-sm text-gray-700">Reject</span>
</label>
</div>
</div>
) : (
<span className="text-sm text-gray-500 italic">
{isApproved ? 'Approved' : isRejected ? 'Rejected' : '-'}
</span>
)}
</td>
<td className="px-6 py-4">
{canMakeDecision && decisions[photo.id] === 'reject' ? (
<textarea
value={rejectionReasons[photo.id] || ''}
onChange={(e) =>
handleRejectionReasonChange(photo.id, e.target.value)
}
placeholder="Optional: Enter rejection reason..."
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md resize-none focus:ring-blue-500 focus:border-blue-500"
rows={2}
/>
) : isRejected && photo.rejection_reason ? (
<div className="text-sm text-gray-700 whitespace-pre-wrap">
<div className="bg-red-50 p-2 rounded border border-red-200">
{photo.rejection_reason}
</div>
</div>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
)
}