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.
605 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
|