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.
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 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>
|
|
);
|
|
}
|
|
|