punimtag/viewer-frontend/components/IdentifyFaceDialog.tsx
Tanya de2144be2a feat: Add new scripts and update project structure for database management and user authentication
This commit introduces several new scripts for managing database operations, including user creation, permission grants, and data migrations. It also adds new documentation files to guide users through the setup and configuration processes. Additionally, the project structure is updated to enhance organization and maintainability, ensuring a smoother development experience for contributors. These changes support the ongoing transition to a web-based architecture and improve overall project functionality.
2026-01-06 13:53:24 -05: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 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 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">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">
{[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>
);
}