diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx index b1d4094..ab50b32 100644 --- a/admin-frontend/src/App.tsx +++ b/admin-frontend/src/App.tsx @@ -24,6 +24,8 @@ import VideoPlayer from './pages/VideoPlayer' import Layout from './components/Layout' import PasswordChangeModal from './components/PasswordChangeModal' import AdminRoute from './components/AdminRoute' +import { ToastProvider } from './context/ToastContext' +import { ConfirmProvider } from './context/ConfirmContext' import { logClick, flushPendingClicks } from './services/clickLogger' function PrivateRoute({ children }: { children: React.ReactNode }) { @@ -171,13 +173,17 @@ function App() { return ( - - - + + + + + + + ) diff --git a/admin-frontend/src/context/ConfirmContext.tsx b/admin-frontend/src/context/ConfirmContext.tsx new file mode 100644 index 0000000..b695698 --- /dev/null +++ b/admin-frontend/src/context/ConfirmContext.tsx @@ -0,0 +1,100 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' + +type ConfirmOptions = { + title?: string + confirmLabel?: string + cancelLabel?: string + variant?: 'default' | 'danger' +} + +type ConfirmState = { + message: string + options: ConfirmOptions + resolve: (value: boolean) => void +} + +type ConfirmContextValue = { + confirm: (message: string, options?: ConfirmOptions) => Promise +} + +const ConfirmContext = createContext(null) + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState(null) + const stateRef = useRef(null) + + const confirm = useCallback((message: string, options: ConfirmOptions = {}) => { + return new Promise((resolve) => { + const next: ConfirmState = { message, options, resolve } + stateRef.current = next + setState(next) + }) + }, []) + + const close = useCallback((result: boolean) => { + const current = stateRef.current + if (!current) return + stateRef.current = null + setState(null) + current.resolve(result) + }, []) + + const value = useMemo(() => ({ confirm }), [confirm]) + + return ( + + {children} + {state && ( +
+
+

+ {state.options.title || 'Confirm'} +

+

{state.message}

+
+ + +
+
+
+ )} +
+ ) +} + +export function useConfirm() { + const ctx = useContext(ConfirmContext) + if (!ctx) { + throw new Error('useConfirm must be used within ConfirmProvider') + } + return ctx +} diff --git a/admin-frontend/src/context/ToastContext.tsx b/admin-frontend/src/context/ToastContext.tsx new file mode 100644 index 0000000..b9a84f3 --- /dev/null +++ b/admin-frontend/src/context/ToastContext.tsx @@ -0,0 +1,73 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from 'react' + +export type ToastVariant = 'info' | 'success' | 'error' + +type ToastItem = { + id: number + message: string + variant: ToastVariant +} + +type ToastContextValue = { + showToast: (message: string, variant?: ToastVariant) => void +} + +const ToastContext = createContext(null) + +let toastId = 0 + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const showToast = useCallback((message: string, variant: ToastVariant = 'info') => { + const id = ++toastId + setToasts((prev) => [...prev, { id, message, variant }]) + window.setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, 5000) + }, []) + + const value = useMemo(() => ({ showToast }), [showToast]) + + return ( + + {children} +
+ {toasts.map((toast) => ( +
+ {toast.message} +
+ ))} +
+
+ ) +} + +export function useToast() { + const ctx = useContext(ToastContext) + if (!ctx) { + throw new Error('useToast must be used within ToastProvider') + } + return ctx +} diff --git a/admin-frontend/src/pages/ApproveIdentified.tsx b/admin-frontend/src/pages/ApproveIdentified.tsx index a7730f9..9bc161f 100644 --- a/admin-frontend/src/pages/ApproveIdentified.tsx +++ b/admin-frontend/src/pages/ApproveIdentified.tsx @@ -6,9 +6,13 @@ import pendingIdentificationsApi, { } from '../api/pendingIdentifications' import { apiClient } from '../api/client' import { useAuth } from '../context/AuthContext' +import { useToast } from '../context/ToastContext' +import { useConfirm } from '../context/ConfirmContext' export default function ApproveIdentified() { const { isAdmin } = useAuth() + const { showToast } = useToast() + const { confirm } = useConfirm() const [pendingIdentifications, setPendingIdentifications] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -116,7 +120,7 @@ export default function ApproveIdentified() { const handleApproveNextOnScreen = (limit = 10) => { const ids = actionableIds.slice(0, limit) if (ids.length === 0) { - alert('No pending identifications to approve.') + showToast('No pending identifications to approve.', 'info') return } setDecisionForIds(ids, 'approve') @@ -139,11 +143,12 @@ export default function ApproveIdentified() { })) if (decisionsList.length === 0) { - alert('Please select Approve or Deny for at least one identification.') + showToast('Please select Approve or Deny for at least one identification.', 'error') return } - if (!confirm(`Submit ${decisionsList.length} decision(s)?`)) { + const ok = await confirm(`Submit ${decisionsList.length} decision(s)?`) + if (!ok) { return } @@ -159,7 +164,7 @@ export default function ApproveIdentified() { response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : '' ].filter(Boolean).join('\n') - alert(message) + showToast(message, response.errors.length > 0 ? 'error' : 'success') if (response.errors.length > 0) { console.error('Errors:', response.errors) @@ -171,7 +176,7 @@ export default function ApproveIdentified() { setDecisions({}) } catch (err: any) { const errorMessage = err.response?.data?.detail || err.message || 'Failed to submit decisions' - alert(`Error: ${errorMessage}`) + showToast(`Error: ${errorMessage}`, 'error') console.error('Error submitting decisions:', err) } finally { setSubmitting(false) @@ -216,7 +221,12 @@ export default function ApproveIdentified() { } const handleClearDenied = async () => { - if (!confirm('Are you sure you want to delete all denied records? This action cannot be undone.')) { + const ok = await confirm('Are you sure you want to delete all denied records? This action cannot be undone.', { + title: 'Delete denied records', + confirmLabel: 'Delete', + variant: 'danger', + }) + if (!ok) { return } @@ -229,18 +239,18 @@ export default function ApproveIdentified() { response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : '' ].filter(Boolean).join('\n') - alert(message) + showToast(message, response.errors.length > 0 ? 'error' : 'success') if (response.errors.length > 0) { console.error('Errors:', response.errors) - alert('Errors:\n' + response.errors.join('\n')) + showToast(`Errors: ${response.errors.join('; ')}`, 'error') } // Reload the list to reflect changes await loadPendingIdentifications() } catch (err: any) { const errorMessage = err.response?.data?.detail || err.message || 'Failed to clear denied records' - alert(`Error: ${errorMessage}`) + showToast(`Error: ${errorMessage}`, 'error') console.error('Error clearing denied records:', err) } finally { setClearing(false) diff --git a/admin-frontend/src/pages/AutoMatch.tsx b/admin-frontend/src/pages/AutoMatch.tsx index e024ade..94bd3c5 100644 --- a/admin-frontend/src/pages/AutoMatch.tsx +++ b/admin-frontend/src/pages/AutoMatch.tsx @@ -6,12 +6,16 @@ import facesApi, { import peopleApi, { Person } from '../api/people' import { apiClient } from '../api/client' import { useDeveloperMode } from '../context/DeveloperModeContext' +import { useToast } from '../context/ToastContext' +import { useConfirm } from '../context/ConfirmContext' const DEFAULT_TOLERANCE = 0.6 // Default for regular auto-match (more lenient) const RUN_AUTO_MATCH_TOLERANCE = 0.5 // Tolerance for Run auto-match button (stricter) export default function AutoMatch() { const { isDeveloperMode } = useDeveloperMode() + const { showToast } = useToast() + const { confirm } = useConfirm() const [tolerance, setTolerance] = useState(DEFAULT_TOLERANCE) const [autoAcceptThreshold, setAutoAcceptThreshold] = useState(70) const [isActive, setIsActive] = useState(false) @@ -467,12 +471,12 @@ export default function AutoMatch() { const startAutoMatch = async () => { if (tolerance < 0 || tolerance > 1) { - alert('Please enter a valid tolerance value between 0.0 and 1.0.') + showToast('Please enter a valid tolerance value between 0.0 and 1.0.', 'error') return } if (autoAcceptThreshold < 0 || autoAcceptThreshold > 100) { - alert('Please enter a valid auto-accept threshold between 0 and 100.') + showToast('Please enter a valid auto-accept threshold between 0 and 100.', 'error') return } @@ -488,7 +492,11 @@ export default function AutoMatch() { 'Do you want to proceed with the auto-match operation?' ].join('\n') - if (!confirm(infoMessage)) { + const proceed = await confirm(infoMessage, { + title: 'Bulk Auto-Match', + confirmLabel: 'Run Auto-Match', + }) + if (!proceed) { return } @@ -510,7 +518,7 @@ export default function AutoMatch() { ].filter(Boolean).join('\n') if (summary) { - alert(summary) + showToast(summary, 'success') } // Reload faces after auto-accept to remove auto-accepted faces from the list @@ -520,7 +528,7 @@ export default function AutoMatch() { } if (response.people.length === 0) { - alert('🔍 No similar faces found for auto-identification') + showToast('No similar faces found for auto-identification', 'info') setHasNoResults(true) setPeople([]) setFilteredPeople([]) @@ -538,7 +546,7 @@ export default function AutoMatch() { setIsActive(true) } catch (error) { console.error('Auto-match failed:', error) - alert('Failed to start auto-match. Please try again.') + showToast('Failed to start auto-match. Please try again.', 'error') } finally { setBusy(false) } @@ -589,10 +597,10 @@ export default function AutoMatch() { }) setOriginalSelectedFaces(prev => ({ ...prev, ...newOriginal })) - alert(`✅ Saved ${faceIds.length} match(es)`) + showToast(`Saved ${faceIds.length} match(es)`, 'success') } catch (error) { console.error('Save failed:', error) - alert('Failed to save matches. Please try again.') + showToast('Failed to save matches. Please try again.', 'error') } finally { setSaving(false) } diff --git a/admin-frontend/src/pages/Identify.tsx b/admin-frontend/src/pages/Identify.tsx index 65b21b7..dc6c7ee 100644 --- a/admin-frontend/src/pages/Identify.tsx +++ b/admin-frontend/src/pages/Identify.tsx @@ -11,6 +11,8 @@ import { useAuth } from '../context/AuthContext' import pendingIdentificationsApi, { IdentificationReportResponse, } from '../api/pendingIdentifications' +import { useToast } from '../context/ToastContext' +import { useConfirm } from '../context/ConfirmContext' type SortBy = 'quality' | 'date_taken' | 'date_added' type SortDir = 'asc' | 'desc' @@ -59,6 +61,8 @@ function focusAdjacentIdentifyField(e: KeyboardEvent) { export default function Identify() { const { isDeveloperMode } = useDeveloperMode() const { isAdmin } = useAuth() + const { showToast } = useToast() + const { confirm } = useConfirm() const [searchParams, setSearchParams] = useSearchParams() const [faces, setFaces] = useState([]) const [, setTotal] = useState(0) @@ -774,7 +778,7 @@ export default function Identify() { // Validate that we have either a person ID or both first and last name if (!personId && (!firstName.trim() || !lastName.trim())) { - alert('Please select an existing person or enter first name and last name.') + showToast('Please select an existing person or enter first name and last name.', 'error') return } @@ -840,7 +844,7 @@ export default function Identify() { } catch (error: any) { console.error('Error identifying face:', error) const errorMessage = error.response?.data?.detail || error.message || 'Failed to identify face. Please try again.' - alert(errorMessage) + showToast(errorMessage, 'error') } finally { setBusy(false) } @@ -920,7 +924,7 @@ export default function Identify() { // Don't reload faces - keep UI as is } catch (error) { console.error('Error toggling excluded status:', error) - alert('Failed to update excluded status') + showToast('Failed to update excluded status', 'error') } }, [currentFace, currentIdx, faces.length]) @@ -943,7 +947,7 @@ export default function Identify() { setVideosTotal(response.total) } catch (error) { console.error('Failed to load videos:', error) - alert('Failed to load videos. Please try again.') + showToast('Failed to load videos. Please try again.', 'error') } finally { setVideosLoading(false) } @@ -956,7 +960,7 @@ export default function Identify() { setVideoPeople(response.people) } catch (error) { console.error('Failed to load video people:', error) - alert('Failed to load people for this video.') + showToast('Failed to load people for this video.', 'error') } finally { setVideoPeopleLoading(false) } @@ -988,7 +992,7 @@ export default function Identify() { const trimmedPhone = videoPhone.trim() if (!videoPersonId && (!trimmedFirstName || !trimmedLastName)) { - alert('Please select an existing person or enter first name and last name.') + showToast('Please select an existing person or enter first name and last name.', 'error') return } @@ -1041,7 +1045,7 @@ export default function Identify() { setVideoPhone('') } catch (error: any) { console.error('Failed to identify person in video:', error) - alert(error.response?.data?.detail || 'Failed to identify person in video. Please try again.') + showToast(error.response?.data?.detail || 'Failed to identify person in video. Please try again.', 'error') } finally { setVideoIdentifying(false) } @@ -1050,7 +1054,8 @@ export default function Identify() { const handleRemovePersonFromVideo = async (personId: number) => { if (!selectedVideo) return - if (!confirm('Remove this person from the video?')) { + const ok = await confirm('Remove this person from the video?') + if (!ok) { return } @@ -1064,7 +1069,7 @@ export default function Identify() { await loadVideos() } catch (error: any) { console.error('Failed to remove person from video:', error) - alert(error.response?.data?.detail || 'Failed to remove person from video. Please try again.') + showToast(error.response?.data?.detail || 'Failed to remove person from video. Please try again.', 'error') } }