Sprint C#13: toast + confirm dialogs on admin review pages.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user