diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fac9322..0a1710c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' import { DeveloperModeProvider } from './context/DeveloperModeContext' @@ -12,16 +13,44 @@ import Modify from './pages/Modify' import Tags from './pages/Tags' import FacesMaintenance from './pages/FacesMaintenance' import ApproveIdentified from './pages/ApproveIdentified' +import ManageUsers from './pages/ManageUsers' +import ReportedPhotos from './pages/ReportedPhotos' import Settings from './pages/Settings' import Help from './pages/Help' import Layout from './components/Layout' +import PasswordChangeModal from './components/PasswordChangeModal' +import AdminRoute from './components/AdminRoute' function PrivateRoute({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isLoading } = useAuth() + const { isAuthenticated, isLoading, passwordChangeRequired } = useAuth() + const [showPasswordModal, setShowPasswordModal] = useState(false) + + useEffect(() => { + if (isAuthenticated && passwordChangeRequired) { + setShowPasswordModal(true) + } + }, [isAuthenticated, passwordChangeRequired]) + if (isLoading) { return
Loading...
} - return isAuthenticated ? <>{children} : + + if (!isAuthenticated) { + return + } + + return ( + <> + {showPasswordModal && ( + { + setShowPasswordModal(false) + }} + /> + )} + {children} + + ) } function AppRoutes() { @@ -45,7 +74,30 @@ function AppRoutes() { } /> } /> } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> } /> } /> diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 15eda0d..6a21436 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -8,11 +8,23 @@ export interface LoginRequest { export interface TokenResponse { access_token: string refresh_token: string - token_type: string + token_type?: string + password_change_required?: boolean +} + +export interface PasswordChangeRequest { + current_password: string + new_password: string +} + +export interface PasswordChangeResponse { + success: boolean + message: string } export interface UserResponse { username: string + is_admin?: boolean } export const authApi = { @@ -36,5 +48,15 @@ export const authApi = { const { data } = await apiClient.get('/api/v1/auth/me') return data }, + + changePassword: async ( + request: PasswordChangeRequest + ): Promise => { + const { data } = await apiClient.post( + '/api/v1/auth/change-password', + request + ) + return data + }, } diff --git a/frontend/src/api/reportedPhotos.ts b/frontend/src/api/reportedPhotos.ts new file mode 100644 index 0000000..d4252e2 --- /dev/null +++ b/frontend/src/api/reportedPhotos.ts @@ -0,0 +1,58 @@ +import apiClient from './client' + +export interface ReportedPhotoResponse { + id: number + photo_id: number + user_id: number + user_name: string | null + user_email: string | null + status: string + reported_at: string + reviewed_at: string | null + reviewed_by: number | null + review_notes: string | null + photo_path: string | null + photo_filename: string | null +} + +export interface ReportedPhotosListResponse { + items: ReportedPhotoResponse[] + total: number +} + +export interface ReviewDecision { + id: number + decision: 'keep' | 'remove' + review_notes?: string | null +} + +export interface ReviewRequest { + decisions: ReviewDecision[] +} + +export interface ReviewResponse { + kept: number + removed: number + errors: string[] +} + +export const reportedPhotosApi = { + listReportedPhotos: async (statusFilter?: string): Promise => { + const { data } = await apiClient.get( + '/api/v1/reported-photos', + { + params: statusFilter ? { status_filter: statusFilter } : undefined, + } + ) + return data + }, + + reviewReportedPhotos: async (request: ReviewRequest): Promise => { + const { data } = await apiClient.post( + '/api/v1/reported-photos/review', + request + ) + return data + }, +} + diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..98cbc1a --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,72 @@ +import apiClient from './client' + +export interface UserResponse { + id: number + username: string + email: string | null + full_name: string | null + is_active: boolean + is_admin: boolean + created_date: string + last_login: string | null +} + +export interface UserCreateRequest { + username: string + password: string + email?: string | null + full_name?: string | null + is_active?: boolean + is_admin?: boolean +} + +export interface UserUpdateRequest { + password?: string | null + email?: string | null + full_name?: string | null + is_active?: boolean + is_admin?: boolean +} + +export interface UsersListResponse { + items: UserResponse[] + total: number +} + +export const usersApi = { + listUsers: async (params?: { + is_active?: boolean + is_admin?: boolean + }): Promise => { + const { data } = await apiClient.get('/api/v1/users', { + params, + }) + return data + }, + + getUser: async (userId: number): Promise => { + const { data } = await apiClient.get(`/api/v1/users/${userId}`) + return data + }, + + createUser: async (request: UserCreateRequest): Promise => { + const { data } = await apiClient.post('/api/v1/users', request) + return data + }, + + updateUser: async ( + userId: number, + request: UserUpdateRequest + ): Promise => { + const { data } = await apiClient.put( + `/api/v1/users/${userId}`, + request + ) + return data + }, + + deleteUser: async (userId: number): Promise => { + await apiClient.delete(`/api/v1/users/${userId}`) + }, +} + diff --git a/frontend/src/components/AdminRoute.tsx b/frontend/src/components/AdminRoute.tsx new file mode 100644 index 0000000..5628fb3 --- /dev/null +++ b/frontend/src/components/AdminRoute.tsx @@ -0,0 +1,25 @@ +import { Navigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +interface AdminRouteProps { + children: React.ReactNode +} + +export default function AdminRoute({ children }: AdminRouteProps) { + const { isAuthenticated, isLoading, isAdmin } = useAuth() + + if (isLoading) { + return
Loading...
+ } + + if (!isAuthenticated) { + return + } + + if (!isAdmin) { + return + } + + return <>{children} +} + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 47fca52..f92aef2 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -3,22 +3,27 @@ import { useAuth } from '../context/AuthContext' export default function Layout() { const location = useLocation() - const { username, logout } = useAuth() + const { username, logout, isAdmin } = useAuth() - const navItems = [ - { path: '/scan', label: 'Scan', icon: 'đŸ—‚ī¸' }, - { path: '/process', label: 'Process', icon: 'âš™ī¸' }, - { path: '/search', label: 'Search', icon: '🔍' }, - { path: '/identify', label: 'Identify', icon: '👤' }, - { path: '/auto-match', label: 'Auto-Match', icon: '🤖' }, - { path: '/modify', label: 'Modify', icon: 'âœī¸' }, - { path: '/tags', label: 'Tag', icon: 'đŸˇī¸' }, - { path: '/faces-maintenance', label: 'Faces Maintenance', icon: '🔧' }, - { path: '/approve-identified', label: 'Approve identified', icon: '✅' }, - { path: '/settings', label: 'Settings', icon: 'âš™ī¸' }, - { path: '/help', label: 'Help', icon: '📚' }, + const allNavItems = [ + { path: '/scan', label: 'Scan', icon: 'đŸ—‚ī¸', adminOnly: false }, + { path: '/process', label: 'Process', icon: 'âš™ī¸', adminOnly: false }, + { path: '/search', label: 'Search', icon: '🔍', adminOnly: false }, + { path: '/identify', label: 'Identify', icon: '👤', adminOnly: false }, + { path: '/auto-match', label: 'Auto-Match', icon: '🤖', adminOnly: false }, + { path: '/modify', label: 'Modify', icon: 'âœī¸', adminOnly: false }, + { path: '/tags', label: 'Tag', icon: 'đŸˇī¸', adminOnly: false }, + { path: '/faces-maintenance', label: 'Faces Maintenance', icon: '🔧', adminOnly: false }, + { 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: '/settings', label: 'Settings', icon: 'âš™ī¸', adminOnly: false }, + { path: '/help', label: 'Help', icon: '📚', adminOnly: false }, ] + // Filter nav items based on admin status + const navItems = allNavItems.filter((item) => !item.adminOnly || isAdmin) + return (
{/* Top bar */} diff --git a/frontend/src/components/PasswordChangeModal.tsx b/frontend/src/components/PasswordChangeModal.tsx new file mode 100644 index 0000000..2102310 --- /dev/null +++ b/frontend/src/components/PasswordChangeModal.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react' +import { authApi } from '../api/auth' +import { useAuth } from '../context/AuthContext' + +interface PasswordChangeModalProps { + onSuccess: () => void +} + +export default function PasswordChangeModal({ onSuccess }: PasswordChangeModalProps) { + const { clearPasswordChangeRequired } = useAuth() + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + // Validation + if (!currentPassword || !newPassword || !confirmPassword) { + setError('All fields are required') + return + } + + if (newPassword.length < 6) { + setError('New password must be at least 6 characters') + return + } + + if (newPassword !== confirmPassword) { + setError('New passwords do not match') + return + } + + if (currentPassword === newPassword) { + setError('New password must be different from current password') + return + } + + try { + setLoading(true) + await authApi.changePassword({ + current_password: currentPassword, + new_password: newPassword, + }) + clearPasswordChangeRequired() + onSuccess() + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to change password') + } finally { + setLoading(false) + } + } + + return ( +
+
+

Change Password Required

+

+ You must change your password before continuing. Please enter your current password + (provided by your administrator) and choose a new password. +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setCurrentPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md" + required + autoFocus + /> +
+ +
+ + setNewPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md" + required + minLength={6} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md" + required + minLength={6} + /> +
+ +
+ +
+
+
+
+ ) +} + diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 9ad80c5..d0e67da 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -5,11 +5,14 @@ interface AuthState { isAuthenticated: boolean username: string | null isLoading: boolean + passwordChangeRequired: boolean + isAdmin: boolean } interface AuthContextType extends AuthState { - login: (username: string, password: string) => Promise<{ success: boolean; error?: string }> + login: (username: string, password: string) => Promise<{ success: boolean; error?: string; passwordChangeRequired?: boolean }> logout: () => void + clearPasswordChangeRequired: () => void } const AuthContext = createContext(undefined) @@ -19,6 +22,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: false, username: null, isLoading: true, + passwordChangeRequired: false, + isAdmin: false, }) useEffect(() => { @@ -31,6 +36,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: true, username: user.username, isLoading: false, + passwordChangeRequired: false, + isAdmin: user.is_admin || false, }) }) .catch(() => { @@ -40,6 +47,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: false, username: null, isLoading: false, + passwordChangeRequired: false, + isAdmin: false, }) }) } else { @@ -47,6 +56,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: false, username: null, isLoading: false, + passwordChangeRequired: false, + isAdmin: false, }) } }, []) @@ -57,12 +68,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { localStorage.setItem('access_token', tokens.access_token) localStorage.setItem('refresh_token', tokens.refresh_token) const user = await authApi.me() + const passwordChangeRequired = tokens.password_change_required || false setAuthState({ isAuthenticated: true, username: user.username, isLoading: false, + passwordChangeRequired, + isAdmin: user.is_admin || false, }) - return { success: true } + return { success: true, passwordChangeRequired } } catch (error: any) { return { success: false, @@ -71,6 +85,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { } } + const clearPasswordChangeRequired = () => { + setAuthState((prev) => ({ + ...prev, + passwordChangeRequired: false, + })) + } + const logout = () => { localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') @@ -84,7 +105,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } return ( - + {children} ) diff --git a/frontend/src/pages/ApproveIdentified.tsx b/frontend/src/pages/ApproveIdentified.tsx index e87ce78..64be17f 100644 --- a/frontend/src/pages/ApproveIdentified.tsx +++ b/frontend/src/pages/ApproveIdentified.tsx @@ -58,11 +58,11 @@ export default function ApproveIdentified() { } const handleSubmit = async () => { - // Get all decisions that have been made, but only for pending items + // Get all decisions that have been made, for pending or denied items (not approved) const decisionsList = Object.entries(decisions) .filter(([id, decision]) => { const pending = pendingIdentifications.find(p => p.id === parseInt(id)) - return decision !== null && pending && pending.status === 'pending' + return decision !== null && pending && pending.status !== 'approved' }) .map(([id, decision]) => ({ id: parseInt(id), @@ -193,7 +193,7 @@ export default function ApproveIdentified() { const isDenied = pending.status === 'denied' const isApproved = pending.status === 'approved' return ( - +
{formatName(pending)} @@ -265,34 +265,37 @@ export default function ApproveIdentified() {
- {isDenied ? ( -
Denied
- ) : isApproved ? ( + {isApproved ? (
Approved
) : ( -
- - +
+ {isDenied && ( + (Denied) + )} +
+ + +
)} diff --git a/frontend/src/pages/AutoMatch.tsx b/frontend/src/pages/AutoMatch.tsx index 6aa2709..a071964 100644 --- a/frontend/src/pages/AutoMatch.tsx +++ b/frontend/src/pages/AutoMatch.tsx @@ -624,7 +624,7 @@ export default function AutoMatch() {
- Person {currentIndex + 1} of {activePeople.length} + Person {currentIndex + 1}
- Person {currentIndex + 1} of {activePeople.length} + Person {currentIndex + 1} {currentPerson && ` â€ĸ ${currentPerson.total_matches} matches`}
diff --git a/frontend/src/pages/ManageUsers.tsx b/frontend/src/pages/ManageUsers.tsx new file mode 100644 index 0000000..2b490ff --- /dev/null +++ b/frontend/src/pages/ManageUsers.tsx @@ -0,0 +1,473 @@ +import { useState, useEffect } from 'react' +import { usersApi, UserResponse, UserCreateRequest, UserUpdateRequest } from '../api/users' + +export default function ManageUsers() { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showCreateModal, setShowCreateModal] = useState(false) + const [editingUser, setEditingUser] = useState(null) + const [filterActive, setFilterActive] = useState(null) + const [filterAdmin, setFilterAdmin] = useState(null) + + const [createForm, setCreateForm] = useState({ + username: '', + password: '', + email: '', + full_name: '', + is_active: true, + is_admin: false, + }) + + const [editForm, setEditForm] = useState({ + password: '', + email: '', + full_name: '', + is_active: true, + is_admin: false, + }) + + useEffect(() => { + loadUsers() + }, [filterActive, filterAdmin]) + + const loadUsers = async () => { + try { + setLoading(true) + setError(null) + const params: { is_active?: boolean; is_admin?: boolean } = {} + if (filterActive !== null) params.is_active = filterActive + if (filterAdmin !== null) params.is_admin = filterAdmin + const response = await usersApi.listUsers(params) + setUsers(response.items) + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to load users') + } finally { + setLoading(false) + } + } + + const handleCreate = async () => { + try { + setError(null) + await usersApi.createUser(createForm) + setShowCreateModal(false) + setCreateForm({ + username: '', + password: '', + email: '', + full_name: '', + is_active: true, + is_admin: false, + }) + loadUsers() + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to create user') + } + } + + const handleUpdate = async () => { + if (!editingUser) return + try { + setError(null) + // Only send password if it's not empty + const updateData: UserUpdateRequest = { + ...editForm, + password: editForm.password && editForm.password.trim() !== '' + ? editForm.password + : undefined, + } + await usersApi.updateUser(editingUser.id, updateData) + setEditingUser(null) + setEditForm({ + password: '', + email: '', + full_name: '', + is_active: true, + is_admin: false, + }) + loadUsers() + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to update user') + } + } + + const handleDelete = async (userId: number, username: string) => { + if (!confirm(`Are you sure you want to delete user "${username}"?`)) { + return + } + try { + setError(null) + await usersApi.deleteUser(userId) + loadUsers() + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to delete user') + } + } + + const startEdit = (user: UserResponse) => { + setEditingUser(user) + setEditForm({ + password: '', + email: user.email || '', + full_name: user.full_name || '', + is_active: user.is_active, + is_admin: user.is_admin, + }) + } + + const formatDate = (dateString: string | null) => { + if (!dateString) return 'Never' + return new Date(dateString).toLocaleString() + } + + return ( +
+
+

Manage Users

+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Filters */} +
+
+ + + +
+
+ + {/* Users Table */} +
+ {loading ? ( +
Loading users...
+ ) : users.length === 0 ? ( +
No users found
+ ) : ( + + + + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + + + ))} + +
+ Username + + Email + + Full Name + + Status + + Role + + Created + + Last Login + + Actions +
+ {user.username} + + {user.email || '-'} + + {user.full_name || '-'} + + + {user.is_active ? 'Active' : 'Inactive'} + + + + {user.is_admin ? 'Admin' : 'User'} + + + {formatDate(user.created_date)} + + {formatDate(user.last_login)} + + + +
+ )} +
+ + {/* Create Modal */} + {showCreateModal && ( +
+
+

Create New User

+
+
+ + + setCreateForm({ ...createForm, username: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + required + /> +
+
+ + + setCreateForm({ ...createForm, password: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + required + minLength={6} + /> +
+
+ + + setCreateForm({ ...createForm, email: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+
+ + + setCreateForm({ ...createForm, full_name: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+
+ + +
+
+
+ + +
+
+
+ )} + + {/* Edit Modal */} + {editingUser && ( +
+
+

+ Edit User: {editingUser.username} +

+
+
+ + + setEditForm({ ...editForm, password: e.target.value || null }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + minLength={6} + placeholder="Leave empty to keep current password" + /> +

+ Minimum 6 characters. Leave empty to keep the current password. +

+
+
+ + + setEditForm({ ...editForm, email: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+
+ + + setEditForm({ ...editForm, full_name: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md" + /> +
+
+ + +
+
+
+ + +
+
+
+ )} +
+ ) +} + diff --git a/frontend/src/pages/ReportedPhotos.tsx b/frontend/src/pages/ReportedPhotos.tsx new file mode 100644 index 0000000..8766b0a --- /dev/null +++ b/frontend/src/pages/ReportedPhotos.tsx @@ -0,0 +1,368 @@ +import { useEffect, useState, useCallback } from 'react' +import { + reportedPhotosApi, + ReportedPhotoResponse, + ReviewDecision, +} from '../api/reportedPhotos' +import { apiClient } from '../api/client' + +export default function ReportedPhotos() { + const [reportedPhotos, setReportedPhotos] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [decisions, setDecisions] = useState>({}) + const [reviewNotes, setReviewNotes] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [statusFilter, setStatusFilter] = useState('pending') + + const loadReportedPhotos = useCallback(async () => { + setLoading(true) + setError(null) + try { + const response = await reportedPhotosApi.listReportedPhotos( + statusFilter || undefined + ) + setReportedPhotos(response.items) + + // Initialize review notes from existing data + const existingNotes: Record = {} + response.items.forEach((item) => { + if (item.review_notes) { + existingNotes[item.id] = item.review_notes + } + }) + setReviewNotes(existingNotes) + } catch (err: any) { + setError(err.response?.data?.detail || err.message || 'Failed to load reported photos') + console.error('Error loading reported photos:', err) + } finally { + setLoading(false) + } + }, [statusFilter]) + + useEffect(() => { + loadReportedPhotos() + }, [loadReportedPhotos]) + + const formatDate = (dateString: string | null | undefined): string => { + if (!dateString) return '-' + try { + const date = new Date(dateString) + return date.toLocaleString() + } catch { + return dateString + } + } + + const handleDecisionChange = (id: number, decision: 'keep' | 'remove') => { + setDecisions((prev) => ({ + ...prev, + [id]: decision, + })) + } + + const handleReviewNotesChange = (id: number, notes: string) => { + setReviewNotes((prev) => ({ + ...prev, + [id]: notes, + })) + } + + const handleSubmit = async () => { + // Get all decisions that have been made for pending or reviewed items + const decisionsList: ReviewDecision[] = Object.entries(decisions) + .filter(([id, decision]) => { + const reported = reportedPhotos.find((p) => p.id === parseInt(id)) + return decision !== null && reported && (reported.status === 'pending' || reported.status === 'reviewed') + }) + .map(([id, decision]) => ({ + id: parseInt(id), + decision: decision!, + review_notes: reviewNotes[parseInt(id)] || null, + })) + + if (decisionsList.length === 0) { + alert('Please select Keep or Remove for at least one reported photo.') + return + } + + if ( + !confirm( + `Submit ${decisionsList.length} decision(s)?\n\nThis will ${ + decisionsList.filter((d) => d.decision === 'remove').length + } remove photo(s) and ${ + decisionsList.filter((d) => d.decision === 'keep').length + } keep photo(s).` + ) + ) { + return + } + + setSubmitting(true) + try { + const response = await reportedPhotosApi.reviewReportedPhotos({ + decisions: decisionsList, + }) + + const message = [ + `✅ Kept: ${response.kept}`, + `❌ Removed: ${response.removed}`, + 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 loadReportedPhotos() + // Clear decisions and notes + setDecisions({}) + setReviewNotes({}) + } 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 ( +
+
+

Reported Photos

+ + {loading && ( +
+

Loading reported photos...

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

Error loading data

+

{error}

+ +
+ )} + + {!loading && !error && ( + <> +
+
+
+ Total reported photos: {reportedPhotos.length} +
+ +
+ +
+ + {reportedPhotos.length === 0 ? ( +
+

No reported photos found.

+
+ ) : ( +
+ + + + + + + + + + + + + {reportedPhotos.map((reported) => { + const isReviewed = reported.status === 'reviewed' + const isDismissed = reported.status === 'dismissed' + return ( + + + + + + +
+ Photo + + Reported By + + Reported At + + Status + + Decision + + Review Notes +
+
+ {reported.photo_id ? ( +
{ + const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${reported.photo_id}/image` + window.open(photoUrl, '_blank') + }} + title="Click to open full photo" + > + {`Photo { + 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 = `#${reported.photo_id}` + parent.appendChild(fallback) + } + }} + /> +
+ ) : ( +
Photo not found
+ )} +
+
+ {reported.photo_filename || `Photo #${reported.photo_id}`} +
+
+
+ {reported.user_name || 'Unknown'} +
+
+ {reported.user_email || '-'} +
+
+
+ {formatDate(reported.reported_at)} +
+
+ + {reported.status} + + +
+ + +
+
+ {isReviewed || isDismissed ? ( +
+ {reported.review_notes ? ( +
+ {reported.review_notes} +
+ ) : ( + - + )} +
+ ) : ( +
+ {reported.review_notes && ( +
+
+ Existing notes: +
+
+ {reported.review_notes} +
+
+ )} +