Sprint C#13: toast + confirm dialogs on admin review pages.

This commit is contained in:
2026-08-04 22:03:35 -04:00
parent 3867e102ea
commit 7e92afe7c5
6 changed files with 235 additions and 33 deletions
+13 -7
View File
@@ -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 (
<AuthProvider>
<DeveloperModeProvider>
<BrowserRouter
basename={
import.meta.env.BASE_URL.replace(/\/$/, '') || undefined
}
>
<AppRoutes />
</BrowserRouter>
<ToastProvider>
<ConfirmProvider>
<BrowserRouter
basename={
import.meta.env.BASE_URL.replace(/\/$/, '') || undefined
}
>
<AppRoutes />
</BrowserRouter>
</ConfirmProvider>
</ToastProvider>
</DeveloperModeProvider>
</AuthProvider>
)
@@ -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<boolean>
}
const ConfirmContext = createContext<ConfirmContextValue | null>(null)
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<ConfirmState | null>(null)
const stateRef = useRef<ConfirmState | null>(null)
const confirm = useCallback((message: string, options: ConfirmOptions = {}) => {
return new Promise<boolean>((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 (
<ConfirmContext.Provider value={value}>
{children}
{state && (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
>
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h2 id="confirm-dialog-title" className="text-lg font-semibold text-gray-900">
{state.options.title || 'Confirm'}
</h2>
<p className="mt-3 whitespace-pre-wrap text-sm text-gray-700">{state.message}</p>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
className="rounded border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => close(false)}
>
{state.options.cancelLabel || 'Cancel'}
</button>
<button
type="button"
className={`rounded px-4 py-2 text-sm text-white ${
state.options.variant === 'danger'
? 'bg-red-600 hover:bg-red-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
onClick={() => close(true)}
>
{state.options.confirmLabel || 'OK'}
</button>
</div>
</div>
</div>
)}
</ConfirmContext.Provider>
)
}
export function useConfirm() {
const ctx = useContext(ConfirmContext)
if (!ctx) {
throw new Error('useConfirm must be used within ConfirmProvider')
}
return ctx
}
@@ -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<ToastContextValue | null>(null)
let toastId = 0
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
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 (
<ToastContext.Provider value={value}>
{children}
<div
className="pointer-events-none fixed bottom-4 right-4 z-[100] flex max-w-sm flex-col gap-2"
aria-live="polite"
aria-relevant="additions"
>
{toasts.map((toast) => (
<div
key={toast.id}
role="status"
className={`pointer-events-auto rounded-md px-4 py-3 text-sm shadow-lg ${
toast.variant === 'success'
? 'bg-green-700 text-white'
: toast.variant === 'error'
? 'bg-red-700 text-white'
: 'bg-gray-900 text-white'
}`}
>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
)
}
export function useToast() {
const ctx = useContext(ToastContext)
if (!ctx) {
throw new Error('useToast must be used within ToastProvider')
}
return ctx
}
+19 -9
View File
@@ -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<PendingIdentification[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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)
+16 -8
View File
@@ -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)
}
+14 -9
View File
@@ -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<HTMLElement>) {
export default function Identify() {
const { isDeveloperMode } = useDeveloperMode()
const { isAdmin } = useAuth()
const { showToast } = useToast()
const { confirm } = useConfirm()
const [searchParams, setSearchParams] = useSearchParams()
const [faces, setFaces] = useState<FaceItem[]>([])
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')
}
}