feat: Add pending linkages management API and user interface for tag approvals

This commit introduces a new API for managing pending tag linkages, allowing admins to review and approve or deny user-suggested tags. The frontend has been updated with a new User Tagged Photos page for displaying pending linkages, including options for filtering and submitting decisions. Additionally, the Layout component has been modified to include navigation to the new page. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09
2025-11-27 14:40:43 -05:00
parent 999e79f859
commit d5d6dc82b1
10 changed files with 1627 additions and 3 deletions
+9
View File
@@ -16,6 +16,7 @@ import ApproveIdentified from './pages/ApproveIdentified'
import ManageUsers from './pages/ManageUsers'
import ReportedPhotos from './pages/ReportedPhotos'
import PendingPhotos from './pages/PendingPhotos'
import UserTaggedPhotos from './pages/UserTaggedPhotos'
import ManagePhotos from './pages/ManagePhotos'
import Settings from './pages/Settings'
import Help from './pages/Help'
@@ -101,6 +102,14 @@ function AppRoutes() {
</AdminRoute>
}
/>
<Route
path="pending-linkages"
element={
<AdminRoute featureKey="user_tagged">
<UserTaggedPhotos />
</AdminRoute>
}
/>
<Route
path="pending-photos"
element={
+70
View File
@@ -0,0 +1,70 @@
import apiClient from './client'
export interface PendingLinkageResponse {
id: number
photo_id: number
tag_id: number | null
proposed_tag_name: string | null
resolved_tag_name: string | null
user_id: number
user_name: string | null
user_email: string | null
status: string
notes: string | null
created_at: string
updated_at: string | null
photo_filename: string | null
photo_path: string | null
photo_tags: string[]
}
export interface PendingLinkagesListResponse {
items: PendingLinkageResponse[]
total: number
}
export interface ReviewDecision {
id: number
decision: 'approve' | 'deny'
}
export interface ReviewRequest {
decisions: ReviewDecision[]
}
export interface ReviewResponse {
approved: number
denied: number
tags_created: number
linkages_created: number
errors: string[]
}
export interface CleanupResponse {
deleted_records: number
errors: string[]
warnings?: string[]
}
export const pendingLinkagesApi = {
async listPendingLinkages(statusFilter?: string): Promise<PendingLinkagesListResponse> {
const { data } = await apiClient.get<PendingLinkagesListResponse>('/api/v1/pending-linkages', {
params: statusFilter ? { status_filter: statusFilter } : undefined,
})
return data
},
async reviewPendingLinkages(request: ReviewRequest): Promise<ReviewResponse> {
const { data } = await apiClient.post<ReviewResponse>('/api/v1/pending-linkages/review', request)
return data
},
async cleanupPendingLinkages(): Promise<CleanupResponse> {
const { data } = await apiClient.post<CleanupResponse>('/api/v1/pending-linkages/cleanup', {})
return data
},
}
export default pendingLinkagesApi
+1
View File
@@ -41,6 +41,7 @@ export default function Layout() {
{ path: '/faces-maintenance', label: 'Faces', icon: '🔧', featureKey: 'faces_maintenance' },
{ path: '/approve-identified', label: 'User Identified Faces', icon: '✅', featureKey: 'user_identified' },
{ path: '/reported-photos', label: 'User Reported Photos', icon: '🚩', featureKey: 'user_reported' },
{ path: '/pending-linkages', label: 'User Tagged Photos', icon: '🔖', featureKey: 'user_tagged' },
{ path: '/pending-photos', label: 'User Uploaded Photos', icon: '📤', featureKey: 'user_uploaded' },
{ path: '/manage-users', label: 'Users', icon: '👥', featureKey: 'manage_users' },
]
+447
View File
@@ -0,0 +1,447 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
pendingLinkagesApi,
PendingLinkageResponse,
ReviewDecision,
} from '../api/pendingLinkages'
import { apiClient } from '../api/client'
import { useAuth } from '../context/AuthContext'
type DecisionValue = 'approve' | 'deny'
function formatDate(value: string | null | undefined): string {
if (!value) {
return '-'
}
try {
return new Date(value).toLocaleString()
} catch (error) {
console.error('Failed to format date', error)
return value
}
}
export default function UserTaggedPhotos() {
const { isAdmin } = useAuth()
const [linkages, setLinkages] = useState<PendingLinkageResponse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [statusFilter, setStatusFilter] = useState<string>('pending')
const [decisions, setDecisions] = useState<Record<number, DecisionValue | null>>({})
const [submitting, setSubmitting] = useState(false)
const [clearing, setClearing] = useState(false)
const loadLinkages = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await pendingLinkagesApi.listPendingLinkages(
statusFilter || undefined
)
setLinkages(response.items)
setDecisions({})
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load user tagged photos')
console.error('Error loading pending linkages:', err)
} finally {
setLoading(false)
}
}, [statusFilter])
useEffect(() => {
loadLinkages()
}, [loadLinkages])
const pendingCount = useMemo(
() => linkages.filter((item) => item.status === 'pending').length,
[linkages]
)
const hasPendingDecision = useMemo(
() =>
Object.entries(decisions).some(([id, value]) => {
const linkage = linkages.find((item) => item.id === Number(id))
return value !== null && linkage?.status === 'pending'
}),
[decisions, linkages]
)
const handleDecisionChange = (id: number, nextDecision: DecisionValue) => {
setDecisions((prev) => {
const current = prev[id] ?? null
const toggled = current === nextDecision ? null : nextDecision
return {
...prev,
[id]: toggled,
}
})
}
const handleSubmit = async () => {
const decisionsList: ReviewDecision[] = Object.entries(decisions)
.filter(([id, decision]) => {
const linkage = linkages.find((item) => item.id === Number(id))
return decision !== null && linkage?.status === 'pending'
})
.map(([id, decision]) => ({
id: Number(id),
decision: decision as DecisionValue,
}))
if (decisionsList.length === 0) {
alert('Select Approve or Deny for at least one pending tag.')
return
}
const approveCount = decisionsList.filter((item) => item.decision === 'approve').length
const denyCount = decisionsList.length - approveCount
const confirmMessage = [
`Submit ${decisionsList.length} decision(s)?`,
approveCount ? `✅ Approve: ${approveCount}` : null,
denyCount ? `❌ Deny: ${denyCount}` : null,
]
.filter(Boolean)
.join('\n')
if (!confirm(confirmMessage)) {
return
}
setSubmitting(true)
try {
const response = await pendingLinkagesApi.reviewPendingLinkages({
decisions: decisionsList,
})
const summary = [
`Approved: ${response.approved}`,
`Denied: ${response.denied}`,
response.tags_created ? `New tags: ${response.tags_created}` : null,
response.linkages_created ? `New linkages: ${response.linkages_created}` : null,
response.errors.length ? `Errors: ${response.errors.join('; ')}` : null,
]
.filter(Boolean)
.join('\n')
alert(summary || 'Review complete.')
await loadLinkages()
setDecisions({})
} catch (err: any) {
const message = err.response?.data?.detail || err.message || 'Failed to submit decisions'
alert(message)
console.error('Error submitting pending linkage decisions:', err)
} finally {
setSubmitting(false)
}
}
const handleClearDatabase = async () => {
const confirmMessage = [
'Delete all approved and denied records?',
'',
'Only records with Pending status will remain.',
'This action cannot be undone.',
].join('\n')
if (!confirm(confirmMessage)) {
return
}
setClearing(true)
try {
const response = await pendingLinkagesApi.cleanupPendingLinkages()
const summary = [
`✅ Deleted ${response.deleted_records} record(s)`,
response.warnings && response.warnings.length > 0
? `ℹ️ ${response.warnings.join('; ')}`
: '',
response.errors.length > 0 ? `⚠️ ${response.errors.join('; ')}` : '',
]
.filter(Boolean)
.join('\n')
alert(summary || 'Cleanup complete.')
if (response.errors.length > 0) {
console.error('Cleanup errors:', response.errors)
}
if (response.warnings && response.warnings.length > 0) {
console.info('Cleanup warnings:', response.warnings)
}
await loadLinkages()
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to cleanup pending linkages'
alert(`Error: ${errorMessage}`)
console.error('Error clearing pending linkages:', err)
} finally {
setClearing(false)
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">User Tagged Photos</h1>
<p className="text-gray-600">
Review tags suggested by users. Approving creates/links the tag to the selected photo.
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
Status
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="border border-gray-300 rounded-md px-3 py-1 text-sm"
>
<option value="pending">Pending</option>
<option value="">All Statuses</option>
<option value="approved">Approved</option>
<option value="denied">Denied</option>
</select>
</label>
<button
type="button"
onClick={loadLinkages}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Refresh
</button>
<div className="text-sm text-gray-500">
Pending items: <span className="font-semibold text-gray-800">{pendingCount}</span>
</div>
<div className="flex-1" />
<button
type="button"
onClick={handleSubmit}
disabled={submitting || loading || !hasPendingDecision}
className={`inline-flex items-center px-4 py-2 rounded-md text-sm font-semibold text-white ${
submitting || loading || !hasPendingDecision
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{submitting ? 'Submitting...' : 'Submit Decisions'}
</button>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => {
if (!isAdmin) {
return
}
handleClearDatabase()
}}
disabled={clearing || !isAdmin}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-200 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
title={
isAdmin
? 'Delete approved/denied records'
: 'Only admins can clear pending linkages'
}
>
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
</button>
<span className="text-sm text-gray-700">
Clear approved/denied records
</span>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
{loading ? (
<div className="flex items-center justify-center h-64 text-gray-500">Loading...</div>
) : linkages.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-lg p-6 text-center text-gray-500">
No user tagged photos found for this filter.
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Photo
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Proposed Tag
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Current Tags
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Submitted By
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Submitted At
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Notes
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Decision
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{linkages.map((linkage) => {
const canReview = linkage.status === 'pending'
const decision = decisions[linkage.id] ?? null
return (
<tr key={linkage.id} className={canReview ? '' : 'opacity-70 bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap">
{linkage.photo_id ? (
<div
className="cursor-pointer hover:opacity-90 transition-opacity w-24"
onClick={() => {
const url = `${apiClient.defaults.baseURL}/api/v1/photos/${linkage.photo_id}/image`
window.open(url, '_blank')
}}
title="Open photo in new tab"
>
<img
src={`/api/v1/photos/${linkage.photo_id}/image`}
alt={`Photo ${linkage.photo_id}`}
className="w-24 h-24 object-cover rounded border border-gray-300"
loading="lazy"
onError={(event) => {
const target = event.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.fallback-text')) {
const fallback = document.createElement('div')
fallback.className = 'fallback-text text-gray-400 text-xs text-center'
fallback.textContent = `#${linkage.photo_id}`
parent.appendChild(fallback)
}
}}
/>
</div>
) : (
<div className="text-xs text-gray-400">Photo not found</div>
)}
<div className="text-xs text-gray-500 mt-1">
{linkage.photo_filename || `Photo #${linkage.photo_id}`}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{linkage.resolved_tag_name || linkage.proposed_tag_name || '-'}
</span>
{linkage.tag_id === null && linkage.proposed_tag_name && (
<span className="text-xs text-yellow-700 bg-yellow-50 px-2 py-0.5 rounded mt-1 inline-flex items-center gap-1">
<span>New tag</span>
</span>
)}
{linkage.tag_id && (
<span className="text-xs text-green-700 bg-green-50 px-2 py-0.5 rounded mt-1 inline-flex items-center gap-1">
<span>Existing tag</span>
<span>#{linkage.tag_id}</span>
</span>
)}
</div>
</td>
<td className="px-6 py-4">
{linkage.photo_tags.length === 0 ? (
<span className="text-sm text-gray-400 italic">No tags</span>
) : (
<div className="flex flex-wrap gap-2">
{linkage.photo_tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-xs bg-gray-100 text-gray-700 rounded-full"
>
{tag}
</span>
))}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{linkage.user_name || 'Unknown'}</div>
<div className="text-xs text-gray-500">{linkage.user_email || '-'}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(linkage.created_at)}
</td>
<td className="px-6 py-4">
{linkage.notes ? (
<div className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 border border-gray-200 rounded p-2">
{linkage.notes}
</div>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
linkage.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: linkage.status === 'approved'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{linkage.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{canReview ? (
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decision === 'approve'}
onChange={() => handleDecisionChange(linkage.id, 'approve')}
className="w-4 h-4 text-green-600 focus:ring-green-500"
/>
<span className="text-sm text-gray-700">Approve</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decision === 'deny'}
onChange={() => handleDecisionChange(linkage.id, 'deny')}
className="w-4 h-4 text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">Deny</span>
</label>
</div>
) : (
<span className="text-sm text-gray-500 italic">-</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}