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:
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user