Files
punimtag/viewer-frontend/components/IdentifyFaceDialog.tsx
T
ilia c011c8078e
CI / skip-ci-check (pull_request) Successful in 4s
CI / docker-ci (pull_request) Successful in 6s
CI / secret-scan (pull_request) Successful in 11s
feat: add dark mode and audit viewer-frontend accessibility
Add next-themes light/dark/system theming with an accessible toggle
in every header, and close out an a11y pass across the viewer app:
skip-to-content link, main landmarks and heading hierarchy on every
route, focus-trapped dialog semantics for the photo viewer modals,
aria-label/live-region coverage for dynamic messages and icon
buttons, and dark: variants for previously light-only colors.
2026-07-14 16:24:51 -04:00

605 lines
21 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { LoginDialog } from '@/components/LoginDialog';
import { RegisterDialog } from '@/components/RegisterDialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
interface IdentifyFaceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
faceId: number;
existingPerson?: {
firstName: string;
lastName: string;
middleName?: string | null;
maidenName?: string | null;
dateOfBirth?: Date | null;
} | null;
onSave: (data: {
personId?: number;
firstName?: string;
lastName?: string;
middleName?: string;
maidenName?: string;
dateOfBirth?: Date;
}) => Promise<void>;
}
export function IdentifyFaceDialog({
open,
onOpenChange,
faceId,
existingPerson,
onSave,
}: IdentifyFaceDialogProps) {
const { data: session, status, update } = useSession();
const router = useRouter();
const [firstName, setFirstName] = useState(existingPerson?.firstName || '');
const [lastName, setLastName] = useState(existingPerson?.lastName || '');
const [middleName, setMiddleName] = useState(existingPerson?.middleName || '');
const [maidenName, setMaidenName] = useState(existingPerson?.maidenName || '');
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<{
firstName?: string;
lastName?: string;
}>({});
const isAuthenticated = status === 'authenticated';
const hasWriteAccess = session?.user?.hasWriteAccess === true;
const isLoading = status === 'loading';
const [mounted, setMounted] = useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [showRegisteredMessage, setShowRegisteredMessage] = useState(false);
const [mode, setMode] = useState<'existing' | 'new'>('existing');
const [people, setPeople] = useState<Array<{
id: number;
firstName: string;
lastName: string;
middleName: string | null;
maidenName: string | null;
dateOfBirth: Date | null;
}>>([]);
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null);
const [peopleSearchQuery, setPeopleSearchQuery] = useState('');
const [peoplePopoverOpen, setPeoplePopoverOpen] = useState(false);
const [loadingPeople, setLoadingPeople] = useState(false);
// Dragging state
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const dialogRef = useRef<HTMLDivElement>(null);
// Prevent hydration mismatch by only rendering on client
useEffect(() => {
setMounted(true);
}, []);
// Reset position when dialog opens
useEffect(() => {
if (open) {
setPosition({ x: 0, y: 0 });
// Reset mode and selected person when dialog opens
setMode('existing');
setSelectedPersonId(null);
setPeopleSearchQuery('');
}
}, [open]);
// Fetch people when dialog opens
useEffect(() => {
if (open && mode === 'existing' && people.length === 0) {
fetchPeople();
}
}, [open, mode]);
const fetchPeople = async () => {
setLoadingPeople(true);
try {
const response = await fetch('/api/people');
if (!response.ok) throw new Error('Failed to fetch people');
const data = await response.json();
setPeople(data.people);
} catch (error) {
console.error('Error fetching people:', error);
} finally {
setLoadingPeople(false);
}
};
// Handle drag start
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault(); // Prevent text selection and other default behaviors
if (dialogRef.current) {
setIsDragging(true);
const rect = dialogRef.current.getBoundingClientRect();
// Calculate the center of the dialog
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
// Store the offset from mouse to dialog center
setDragStart({
x: e.clientX - centerX,
y: e.clientY - centerY,
});
}
};
// Handle dragging
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
// Calculate new position relative to center (50%, 50%)
const newX = e.clientX - window.innerWidth / 2 - dragStart.x;
const newY = e.clientY - window.innerHeight / 2 - dragStart.y;
setPosition({ x: newX, y: newY });
};
const handleMouseUp = () => {
setIsDragging(false);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, dragStart]);
const handleSave = async () => {
// Reset errors
setErrors({});
if (mode === 'existing') {
// Validate person selection
if (!selectedPersonId) {
alert('Please select a person');
return;
}
setIsSaving(true);
try {
await onSave({ personId: selectedPersonId });
// Show success message
alert('Identification submitted successfully! It will be reviewed by an administrator before being applied.');
onOpenChange(false);
} catch (error: any) {
console.error('Error saving face identification:', error);
alert(error.message || 'Failed to submit identification. Please try again.');
} finally {
setIsSaving(false);
}
} else {
// Validate required fields for new person
const newErrors: typeof errors = {};
if (!firstName.trim()) {
newErrors.firstName = 'First name is required';
}
if (!lastName.trim()) {
newErrors.lastName = 'Last name is required';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setIsSaving(true);
try {
await onSave({
firstName: firstName.trim(),
lastName: lastName.trim(),
middleName: middleName.trim() || undefined,
maidenName: maidenName.trim() || undefined,
});
// Show success message
alert('Identification submitted successfully! It will be reviewed by an administrator before being applied.');
onOpenChange(false);
// Reset form after successful save
if (!existingPerson) {
setFirstName('');
setLastName('');
setMiddleName('');
setMaidenName('');
}
} catch (error: any) {
console.error('Error saving face identification:', error);
setErrors({
...errors,
// Show error message
});
alert(error.message || 'Failed to submit identification. Please try again.');
} finally {
setIsSaving(false);
}
}
};
// Prevent hydration mismatch - don't render until mounted
if (!mounted) {
return null;
}
// Handle successful login/register - refresh session
const handleAuthSuccess = async () => {
await update();
router.refresh();
};
// Show login prompt if not authenticated
if (!isLoading && !isAuthenticated) {
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={dialogRef}
className="sm:max-w-[500px]"
style={{
transform: position.x !== 0 || position.y !== 0
? `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))`
: undefined,
cursor: isDragging ? 'grabbing' : undefined,
}}
>
<DialogHeader
onMouseDown={handleMouseDown}
className="cursor-grab active:cursor-grabbing select-none"
>
<DialogTitle>Sign In Required</DialogTitle>
<DialogDescription>
You need to be signed in to identify faces. Your identifications will be submitted for approval.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Please sign in or create an account to continue.
</p>
<div className="flex gap-2">
<Button
onClick={() => {
setLoginDialogOpen(true);
}}
className="flex-1"
>
Sign in
</Button>
<Button
variant="outline"
onClick={() => {
setRegisterDialogOpen(true);
}}
className="flex-1"
>
Register
</Button>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<LoginDialog
open={loginDialogOpen}
onOpenChange={(open) => {
setLoginDialogOpen(open);
if (!open) {
setShowRegisteredMessage(false);
}
}}
onSuccess={handleAuthSuccess}
onOpenRegister={() => {
setLoginDialogOpen(false);
setRegisterDialogOpen(true);
}}
registered={showRegisteredMessage}
callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
/>
<RegisterDialog
open={registerDialogOpen}
onOpenChange={(open) => {
setRegisterDialogOpen(open);
if (!open) {
setShowRegisteredMessage(false);
}
}}
onSuccess={handleAuthSuccess}
onOpenLogin={() => {
setShowRegisteredMessage(true);
setRegisterDialogOpen(false);
setLoginDialogOpen(true);
}}
callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
/>
</>
);
}
// Show write access required message if authenticated but no write access
if (!isLoading && isAuthenticated && !hasWriteAccess) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={dialogRef}
className="sm:max-w-[500px]"
style={{
transform: position.x !== 0 || position.y !== 0
? `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))`
: undefined,
cursor: isDragging ? 'grabbing' : undefined,
}}
>
<DialogHeader
onMouseDown={handleMouseDown}
className="cursor-grab active:cursor-grabbing select-none"
>
<DialogTitle>Write Access Required</DialogTitle>
<DialogDescription>
You need write access to identify faces.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Only users with write access can identify faces. Please contact an administrator to request write access.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
ref={dialogRef}
className="sm:max-w-[500px]"
style={{
transform: position.x !== 0 || position.y !== 0
? `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))`
: undefined,
cursor: isDragging ? 'grabbing' : undefined,
}}
>
<DialogHeader
onMouseDown={handleMouseDown}
className="cursor-grab active:cursor-grabbing select-none"
>
<DialogTitle>Identify Face</DialogTitle>
<DialogDescription>
Choose an existing person or add a new person to identify this face. Your identification will be submitted for approval.
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="py-4 text-center">Loading...</div>
) : (
<div className="grid gap-4 py-4">
{/* Mode selector */}
<div className="flex gap-2 border-b pb-4">
<Button
type="button"
variant={mode === 'existing' ? 'default' : 'outline'}
size="sm"
onClick={() => {
// Clear new person form data when switching to existing mode
setFirstName('');
setLastName('');
setMiddleName('');
setMaidenName('');
setErrors({});
setMode('existing');
}}
className="flex-1"
>
Select Existing Person
</Button>
<Button
type="button"
variant={mode === 'new' ? 'default' : 'outline'}
size="sm"
onClick={() => {
// Clear selected person when switching to new person mode
setSelectedPersonId(null);
setPeopleSearchQuery('');
setPeoplePopoverOpen(false);
setMode('new');
}}
className="flex-1"
>
Add New Person
</Button>
</div>
{mode === 'existing' ? (
<div className="grid gap-2">
<label htmlFor="personSelect" className="text-sm font-medium">
Select Person <span className="text-red-500">*</span>
</label>
<Popover open={peoplePopoverOpen} onOpenChange={setPeoplePopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
disabled={loadingPeople}
>
<Search className="mr-2 h-4 w-4" />
{selectedPersonId
? (() => {
const person = people.find((p) => p.id === selectedPersonId);
return person
? `${person.firstName} ${person.lastName}`
: 'Select a person...';
})()
: loadingPeople
? 'Loading people...'
: 'Select a person...'}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[400px] p-0"
align="start"
onWheel={(event) => {
event.stopPropagation();
}}
>
<div className="p-2">
<Input
placeholder="Search people..."
value={peopleSearchQuery}
onChange={(e) => setPeopleSearchQuery(e.target.value)}
className="mb-2"
/>
<div
className="max-h-[300px] overflow-y-auto"
onWheel={(event) => event.stopPropagation()}
>
{people.filter((person) => {
const fullName = `${person.firstName} ${person.lastName} ${person.middleName || ''} ${person.maidenName || ''}`.toLowerCase();
return fullName.includes(peopleSearchQuery.toLowerCase());
}).length === 0 ? (
<p className="p-2 text-sm text-gray-500 dark:text-gray-400">No people found</p>
) : (
<div className="space-y-1">
{people
.filter((person) => {
const fullName = `${person.firstName} ${person.lastName} ${person.middleName || ''} ${person.maidenName || ''}`.toLowerCase();
return fullName.includes(peopleSearchQuery.toLowerCase());
})
.map((person) => {
const isSelected = selectedPersonId === person.id;
return (
<div
key={person.id}
className={cn(
"flex items-center space-x-2 rounded-md p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800",
isSelected && "bg-gray-100 dark:bg-gray-800"
)}
onClick={() => {
setSelectedPersonId(person.id);
setPeoplePopoverOpen(false);
}}
>
<div className="flex-1">
<div className="text-sm font-medium">
{person.firstName} {person.lastName}
</div>
{(person.middleName || person.maidenName) && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{[person.middleName, person.maidenName].filter(Boolean).join(' • ')}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
) : (
<>
<div className="grid gap-2">
<label htmlFor="firstName" className="text-sm font-medium">
First Name <span className="text-red-500">*</span>
</label>
<Input
id="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Enter first name"
className={cn(errors.firstName && 'border-red-500')}
/>
{errors.firstName && (
<p className="text-sm text-red-500">{errors.firstName}</p>
)}
</div>
<div className="grid gap-2">
<label htmlFor="lastName" className="text-sm font-medium">
Last Name <span className="text-red-500">*</span>
</label>
<Input
id="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Enter last name"
className={cn(errors.lastName && 'border-red-500')}
/>
{errors.lastName && (
<p className="text-sm text-red-500">{errors.lastName}</p>
)}
</div>
<div className="grid gap-2">
<label htmlFor="middleName" className="text-sm font-medium">
Middle Name
</label>
<Input
id="middleName"
value={middleName}
onChange={(e) => setMiddleName(e.target.value)}
placeholder="Enter middle name (optional)"
/>
</div>
<div className="grid gap-2">
<label htmlFor="maidenName" className="text-sm font-medium">
Maiden Name
</label>
<Input
id="maidenName"
value={maidenName}
onChange={(e) => setMaidenName(e.target.value)}
placeholder="Enter maiden name (optional)"
/>
</div>
</>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSaving || isLoading}>
{isSaving ? 'Saving...' : 'Submit for Approval'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}