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.
This commit is contained in:
Tanya
2026-01-06 13:53:24 -05:00
parent 713584dc04
commit de2144be2a
175 changed files with 35854 additions and 0 deletions
@@ -0,0 +1,104 @@
'use client';
import { Button } from '@/components/ui/button';
import { Play, Heart } from 'lucide-react';
interface ActionButtonsProps {
photosCount: number;
isLoggedIn: boolean;
selectedPhotoIds: number[];
selectionMode: boolean;
isBulkFavoriting: boolean;
isPreparingDownload: boolean;
onStartSlideshow: () => void;
onTagSelected: () => void;
onBulkFavorite: () => void;
onDownloadSelected: () => void;
onToggleSelectionMode: () => void;
}
export function ActionButtons({
photosCount,
isLoggedIn,
selectedPhotoIds,
selectionMode,
isBulkFavoriting,
isPreparingDownload,
onStartSlideshow,
onTagSelected,
onBulkFavorite,
onDownloadSelected,
onToggleSelectionMode,
}: ActionButtonsProps) {
if (photosCount === 0) {
return null;
}
return (
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={onStartSlideshow}
className="flex items-center gap-2"
size="sm"
>
<Play className="h-4 w-4" />
Play Slides
</Button>
{isLoggedIn && (
<>
<Button
variant="default"
size="sm"
onClick={onTagSelected}
className="flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white"
disabled={selectedPhotoIds.length === 0}
>
Tag selected
{selectedPhotoIds.length > 0
? ` (${selectedPhotoIds.length})`
: ''}
</Button>
<Button
variant="outline"
size="sm"
onClick={onBulkFavorite}
className="bg-orange-500 hover:bg-orange-600 text-white border-orange-500 hover:border-orange-600 flex items-center gap-1"
disabled={selectedPhotoIds.length === 0 || isBulkFavoriting}
>
<Heart className="h-4 w-4" />
{isBulkFavoriting ? 'Updating...' : 'Favorite selected'}
{!isBulkFavoriting && selectedPhotoIds.length > 0
? ` (${selectedPhotoIds.length})`
: ''}
</Button>
<Button
variant="outline"
size="sm"
onClick={onDownloadSelected}
className="bg-blue-500 hover:bg-blue-600 text-white border-blue-500 hover:border-blue-600"
disabled={selectedPhotoIds.length === 0 || isPreparingDownload}
>
{isPreparingDownload ? 'Preparing download...' : 'Download selected'}
{!isPreparingDownload && selectedPhotoIds.length > 0
? ` (${selectedPhotoIds.length})`
: ''}
</Button>
<Button
variant={selectionMode ? 'secondary' : 'outline'}
size="sm"
onClick={onToggleSelectionMode}
className={selectionMode ? 'bg-blue-400 hover:bg-blue-500 text-white border-blue-400 hover:border-blue-500' : 'bg-blue-400 hover:bg-blue-500 text-white border-blue-400 hover:border-blue-500'}
>
{selectionMode ? 'Done selecting' : 'Select'}
</Button>
</>
)}
</div>
);
}
@@ -0,0 +1,154 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { isValidEmail } from '@/lib/utils';
interface ForgotPasswordDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ForgotPasswordDialog({
open,
onOpenChange,
}: ForgotPasswordDialogProps) {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setEmail('');
setError('');
setSuccess(false);
setIsLoading(false);
}
}, [open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess(false);
if (!email || !isValidEmail(email)) {
setError('Please enter a valid email address');
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to send password reset email');
} else {
setSuccess(true);
setEmail('');
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
setEmail('');
setError('');
setSuccess(false);
}
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reset your password</DialogTitle>
<DialogDescription>
Enter your email address and we'll send you a link to reset your password.
</DialogDescription>
</DialogHeader>
{success ? (
<div className="space-y-4">
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
Password reset email sent! Please check your inbox and follow the instructions to reset your password.
</p>
</div>
<DialogFooter>
<Button onClick={() => handleOpenChange(false)} className="w-full">
Close
</Button>
</DialogFooter>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<div>
<label htmlFor="forgot-email" className="block text-sm font-medium text-secondary dark:text-gray-300">
Email address
</label>
<Input
id="forgot-email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1"
placeholder="you@example.com"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={isLoading}
>
{isLoading ? 'Sending...' : 'Send reset link'}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
}
+191
View File
@@ -0,0 +1,191 @@
'use client';
import { useState } from 'react';
import { useSession, signOut } from 'next-auth/react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { User, LogIn, UserPlus, Users, Home, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { LoginDialog } from '@/components/LoginDialog';
import { RegisterDialog } from '@/components/RegisterDialog';
import { ManageUsersPageClient } from '@/app/admin/users/ManageUsersPageClient';
export function Header() {
const { data: session, status } = useSession();
const router = useRouter();
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [manageUsersOpen, setManageUsersOpen] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const handleSignOut = async () => {
await signOut({ callbackUrl: '/' });
};
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="w-full flex h-16 items-center justify-between px-4">
<div className="flex items-center space-x-3">
{/* Home button - commented out for future use */}
{/* <Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="icon"
asChild
className="bg-primary hover:bg-primary/90 rounded-lg"
>
<Link href="/" aria-label="Home">
<Home className="h-5 w-5" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Go to Home</p>
</TooltipContent>
</Tooltip> */}
</div>
<div className="flex items-center gap-2">
{session?.user && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
asChild
className="bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800"
>
<Link href="/upload" aria-label="Upload Photos">
<Upload className="h-5 w-5" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Upload your own photos</p>
</TooltipContent>
</Tooltip>
)}
{status === 'loading' ? (
<div className="h-9 w-9 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
) : session?.user ? (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full"
aria-label="Account menu"
>
<User className="h-5 w-5 text-orange-600" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="end">
<div className="space-y-1">
<div className="px-2 py-1.5">
<p className="text-sm font-medium text-secondary">
{session.user.name || 'User'}
</p>
<p className="text-xs text-muted-foreground">
{session.user.email}
</p>
</div>
<div className="border-t pt-1">
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
router.push('/upload');
}}
>
<Upload className="mr-2 h-4 w-4" />
Upload Photos
</Button>
{session.user.isAdmin && (
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
setManageUsersOpen(true);
}}
>
<Users className="mr-2 h-4 w-4" />
Manage Users
</Button>
)}
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
handleSignOut();
}}
>
Sign out
</Button>
</div>
</div>
</PopoverContent>
</Popover>
) : (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setLoginDialogOpen(true)}
className="flex items-center gap-2"
>
<LogIn className="h-4 w-4" />
<span className="hidden sm:inline">Sign in</span>
</Button>
<Button
size="sm"
onClick={() => setRegisterDialogOpen(true)}
className="flex items-center gap-2"
>
<UserPlus className="h-4 w-4" />
<span className="hidden sm:inline">Sign up</span>
</Button>
</div>
)}
</div>
</div>
<LoginDialog
open={loginDialogOpen}
onOpenChange={(open) => {
setLoginDialogOpen(open);
}}
onOpenRegister={() => {
setLoginDialogOpen(false);
setRegisterDialogOpen(true);
}}
/>
<RegisterDialog
open={registerDialogOpen}
onOpenChange={(open) => {
setRegisterDialogOpen(open);
}}
onOpenLogin={() => {
setRegisterDialogOpen(false);
setLoginDialogOpen(true);
}}
/>
{manageUsersOpen && (
<ManageUsersPageClient onClose={() => setManageUsersOpen(false)} />
)}
</header>
);
}
@@ -0,0 +1,604 @@
'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>
);
}
@@ -0,0 +1,23 @@
'use client';
import { useIdleLogout } from '@/hooks/useIdleLogout';
/**
* Component that handles idle logout functionality
* Must be rendered inside SessionProvider to use useSession hook
*/
export function IdleLogoutHandler() {
// Log out users after 2 hours of inactivity
useIdleLogout(2 * 60 * 60 * 1000); // 2 hours in milliseconds
// This component doesn't render anything
return null;
}
+304
View File
@@ -0,0 +1,304 @@
'use client';
import { useState, useEffect } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import Link from 'next/link';
import { ForgotPasswordDialog } from '@/components/ForgotPasswordDialog';
interface LoginDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
onOpenRegister?: () => void;
callbackUrl?: string;
registered?: boolean;
}
export function LoginDialog({
open,
onOpenChange,
onSuccess,
onOpenRegister,
callbackUrl: initialCallbackUrl,
registered: initialRegistered,
}: LoginDialogProps) {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = initialCallbackUrl || searchParams.get('callbackUrl') || '/';
const registered = initialRegistered || searchParams.get('registered') === 'true';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [emailNotVerified, setEmailNotVerified] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isResending, setIsResending] = useState(false);
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// Reset all form state when dialog opens
useEffect(() => {
if (open) {
setEmail('');
setPassword('');
setError('');
setEmailNotVerified(false);
setIsLoading(false);
setIsResending(false);
setShowPassword(false);
}
}, [open]);
const handleResendConfirmation = async () => {
setIsResending(true);
try {
const response = await fetch('/api/auth/resend-confirmation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
setError('');
setEmailNotVerified(false);
alert('Confirmation email sent! Please check your inbox.');
} else {
alert(data.error || 'Failed to resend confirmation email');
}
} catch (err) {
alert('An error occurred. Please try again.');
} finally {
setIsResending(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setEmailNotVerified(false);
setIsLoading(true);
try {
// First check if email is verified
const checkResponse = await fetch('/api/auth/check-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const checkData = await checkResponse.json();
if (!checkData.exists) {
setError('Invalid email or password');
setIsLoading(false);
return;
}
if (!checkData.passwordValid) {
setError('Invalid email or password');
setIsLoading(false);
return;
}
if (!checkData.verified) {
setEmailNotVerified(true);
setIsLoading(false);
return;
}
// Email is verified, proceed with login
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Invalid email or password');
} else {
onOpenChange(false);
if (onSuccess) {
onSuccess();
} else {
router.push(callbackUrl);
router.refresh();
}
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
// Reset form when closing
setEmail('');
setPassword('');
setError('');
setEmailNotVerified(false);
setIsResending(false);
}
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Sign in to your account</DialogTitle>
<DialogDescription>
Or{' '}
{onOpenRegister ? (
<button
type="button"
className="font-medium text-secondary hover:text-secondary/80"
onClick={() => {
handleOpenChange(false);
onOpenRegister();
}}
>
create a new account
</button>
) : (
<Link
href="/register"
className="font-medium text-secondary hover:text-secondary/80"
onClick={() => handleOpenChange(false)}
>
create a new account
</Link>
)}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{registered && (
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
Account created successfully! Please check your email to confirm your account before signing in.
</p>
</div>
)}
{searchParams.get('verified') === 'true' && (
<div className="rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-800 dark:text-green-200">
Email verified successfully! You can now sign in.
</p>
</div>
)}
{emailNotVerified && (
<div className="rounded-md bg-yellow-50 p-4 dark:bg-yellow-900/20">
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-2">
Please verify your email address before signing in. Check your inbox for a confirmation email.
</p>
<button
type="button"
onClick={handleResendConfirmation}
disabled={isResending}
className="text-sm text-yellow-900 dark:text-yellow-100 underline hover:no-underline font-medium"
>
{isResending ? 'Sending...' : 'Resend confirmation email'}
</button>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary dark:text-gray-300">
Email address
</label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => {
setEmail(e.target.value);
// Clear email verification error when email changes
if (emailNotVerified) {
setEmailNotVerified(false);
}
}}
className="mt-1"
placeholder="you@example.com"
/>
</div>
<div>
<div className="flex items-center justify-between">
<label htmlFor="password" className="block text-sm font-medium text-secondary dark:text-gray-300">
Password
</label>
<button
type="button"
onClick={() => {
setForgotPasswordOpen(true);
}}
className="text-sm text-secondary hover:text-secondary/80 font-medium"
>
Forgot password?
</button>
</div>
<div className="relative mt-1">
<Input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pr-10"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary hover:text-secondary/80 focus:outline-none"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
<DialogFooter>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</DialogFooter>
</form>
</DialogContent>
<ForgotPasswordDialog
open={forgotPasswordOpen}
onOpenChange={setForgotPasswordOpen}
/>
</Dialog>
);
}
+78
View File
@@ -0,0 +1,78 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
import { ActionButtons } from '@/components/ActionButtons';
interface PageHeaderProps {
photosCount: number;
isLoggedIn: boolean;
selectedPhotoIds: number[];
selectionMode: boolean;
isBulkFavoriting: boolean;
isPreparingDownload: boolean;
onStartSlideshow: () => void;
onTagSelected: () => void;
onBulkFavorite: () => void;
onDownloadSelected: () => void;
onToggleSelectionMode: () => void;
}
export function PageHeader({
photosCount,
isLoggedIn,
selectedPhotoIds,
selectionMode,
isBulkFavoriting,
isPreparingDownload,
onStartSlideshow,
onTagSelected,
onBulkFavorite,
onDownloadSelected,
onToggleSelectionMode,
}: PageHeaderProps) {
return (
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<UserMenu />
</div>
</div>
<div className="flex items-center justify-between gap-4">
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
<ActionButtons
photosCount={photosCount}
isLoggedIn={isLoggedIn}
selectedPhotoIds={selectedPhotoIds}
selectionMode={selectionMode}
isBulkFavoriting={isBulkFavoriting}
isPreparingDownload={isPreparingDownload}
onStartSlideshow={onStartSlideshow}
onTagSelected={onTagSelected}
onBulkFavorite={onBulkFavorite}
onDownloadSelected={onDownloadSelected}
onToggleSelectionMode={onToggleSelectionMode}
/>
</div>
</div>
);
}
+917
View File
@@ -0,0 +1,917 @@
'use client';
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import { Photo, Person } from '@prisma/client';
import Image from 'next/image';
import { Check, Flag, Play, Heart, Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
TooltipProvider,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { parseFaceLocation, isPointInFace } from '@/lib/face-utils';
import { isUrl, isVideo, getImageSrc } from '@/lib/photo-utils';
import { LoginDialog } from '@/components/LoginDialog';
import { RegisterDialog } from '@/components/RegisterDialog';
interface FaceWithLocation {
id: number;
personId: number | null;
location: string;
person: Person | null;
}
interface PhotoWithPeople extends Photo {
faces?: FaceWithLocation[];
}
interface PhotoGridProps {
photos: PhotoWithPeople[];
selectionMode?: boolean;
selectedPhotoIds?: number[];
onToggleSelect?: (photoId: number) => void;
refreshFavoritesKey?: number;
}
/**
* Gets unique people names from photo faces
*/
function getPeopleNames(photo: PhotoWithPeople): string[] {
if (!photo.faces) return [];
const people = photo.faces
.map((face) => face.person)
.filter((person): person is Person => person !== null)
.map((person: any) => {
// Handle both camelCase and snake_case
const firstName = person.firstName || person.first_name || '';
const lastName = person.lastName || person.last_name || '';
return `${firstName} ${lastName}`.trim();
});
// Remove duplicates
return Array.from(new Set(people));
}
const REPORT_COMMENT_MAX_LENGTH = 300;
const getPhotoFilename = (photo: Photo) => {
if (photo?.filename) {
return photo.filename;
}
if (photo?.path) {
const segments = photo.path.split(/[/\\]/);
const lastSegment = segments.pop();
if (lastSegment) {
return lastSegment;
}
}
return `photo-${photo?.id ?? 'download'}.jpg`;
};
const getPhotoDownloadUrl = (
photo: Photo,
options?: { forceProxy?: boolean; watermark?: boolean }
) => {
const path = photo.path || '';
const isExternal = path.startsWith('http://') || path.startsWith('https://');
if (isExternal && !options?.forceProxy) {
return path;
}
const params = new URLSearchParams();
if (options?.watermark) {
params.set('watermark', 'true');
}
const query = params.toString();
return `/api/photos/${photo.id}/image${query ? `?${query}` : ''}`;
};
export function PhotoGrid({
photos,
selectionMode = false,
selectedPhotoIds = [],
onToggleSelect,
refreshFavoritesKey = 0,
}: PhotoGridProps) {
const router = useRouter();
const { data: session, update } = useSession();
const isLoggedIn = Boolean(session);
const hasWriteAccess = session?.user?.hasWriteAccess === true;
// Normalize photos: ensure faces is always available (handle Face vs faces)
const normalizePhoto = (photo: PhotoWithPeople): PhotoWithPeople => {
const normalized = { ...photo };
// If photo has Face (capital F) but no faces (lowercase), convert it
if (!normalized.faces && (normalized as any).Face) {
normalized.faces = (normalized as any).Face.map((face: any) => ({
id: face.id,
personId: face.person_id || face.personId,
location: face.location,
person: face.Person ? {
id: face.Person.id,
firstName: face.Person.first_name,
lastName: face.Person.last_name,
middleName: face.Person.middle_name,
maidenName: face.Person.maiden_name,
dateOfBirth: face.Person.date_of_birth,
} : null,
}));
}
return normalized;
};
// Normalize all photos
const normalizedPhotos = useMemo(() => {
return photos.map(normalizePhoto);
}, [photos]);
const [hoveredFace, setHoveredFace] = useState<{
photoId: number;
faceId: number;
personId: number | null;
personName: string | null;
} | null>(null);
const [reportingPhotoId, setReportingPhotoId] = useState<number | null>(null);
const [reportedPhotos, setReportedPhotos] = useState<Map<number, { status: string }>>(new Map());
const [favoritingPhotoId, setFavoritingPhotoId] = useState<number | null>(null);
const [favoritedPhotos, setFavoritedPhotos] = useState<Map<number, boolean>>(new Map());
const [showSignInRequiredDialog, setShowSignInRequiredDialog] = useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [showRegisteredMessage, setShowRegisteredMessage] = useState(false);
const [reportDialogPhotoId, setReportDialogPhotoId] = useState<number | null>(null);
const [reportDialogComment, setReportDialogComment] = useState('');
const [reportDialogError, setReportDialogError] = useState<string | null>(null);
const imageRefs = useRef<Map<number, { naturalWidth: number; naturalHeight: number }>>(new Map());
const handleMouseMove = useCallback((
e: React.MouseEvent<HTMLDivElement | HTMLButtonElement>,
photo: PhotoWithPeople
) => {
// Skip face detection for videos
if (isVideo(photo)) {
setHoveredFace(null);
return;
}
if (!photo.faces || photo.faces.length === 0) {
setHoveredFace(null);
return;
}
const container = e.currentTarget;
const rect = container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get image dimensions from cache
const imageData = imageRefs.current.get(photo.id);
if (!imageData) {
setHoveredFace(null);
return;
}
const { naturalWidth, naturalHeight } = imageData;
const containerWidth = rect.width;
const containerHeight = rect.height;
// Check each face to see if mouse is over it
for (const face of photo.faces) {
const location = parseFaceLocation(face.location);
if (!location) continue;
if (
isPointInFace(
mouseX,
mouseY,
location,
naturalWidth,
naturalHeight,
containerWidth,
containerHeight
)
) {
// Face detected!
const person = face.person as any; // Handle both camelCase and snake_case
const personName = person
? `${person.firstName || person.first_name || ''} ${person.lastName || person.last_name || ''}`.trim()
: null;
setHoveredFace({
photoId: photo.id,
faceId: face.id,
personId: face.personId,
personName: personName || null,
});
return;
}
}
// No face detected
setHoveredFace(null);
}, []);
const handleImageLoad = useCallback((photoId: number, img: HTMLImageElement) => {
imageRefs.current.set(photoId, {
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
});
}, []);
const handleDownloadPhoto = useCallback((event: React.MouseEvent, photo: Photo) => {
event.stopPropagation();
const link = document.createElement('a');
link.href = getPhotoDownloadUrl(photo, { watermark: !isLoggedIn });
link.download = getPhotoFilename(photo);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [isLoggedIn]);
// Remove duplicates by ID to prevent React key errors
// Memoized to prevent recalculation on every render
// Must be called before any early returns to maintain hooks order
const uniquePhotos = useMemo(() => {
return normalizedPhotos.filter((photo, index, self) =>
index === self.findIndex((p) => p.id === photo.id)
);
}, [normalizedPhotos]);
// Fetch report status for all photos when component mounts or photos change
// Uses batch API to reduce N+1 query problem
// Must be called before any early returns to maintain hooks order
useEffect(() => {
if (!session?.user?.id) {
setReportedPhotos(new Map());
return;
}
const fetchReportStatuses = async () => {
const photoIds = uniquePhotos.map(p => p.id);
if (photoIds.length === 0) {
return;
}
try {
// Batch API call - single request for all photos
const response = await fetch('/api/photos/reports/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ photoIds }),
});
if (!response.ok) {
throw new Error('Failed to fetch report statuses');
}
const data = await response.json();
const statusMap = new Map<number, { status: string }>();
// Process batch results
if (data.results) {
for (const [photoIdStr, result] of Object.entries(data.results)) {
const photoId = parseInt(photoIdStr, 10);
const reportData = result as { reported: boolean; status?: string };
if (reportData.reported && reportData.status) {
statusMap.set(photoId, { status: reportData.status });
}
}
}
setReportedPhotos(statusMap);
} catch (error) {
console.error('Error fetching batch report statuses:', error);
// Fallback: set empty map on error
setReportedPhotos(new Map());
}
};
fetchReportStatuses();
}, [uniquePhotos, session?.user?.id]);
// Fetch favorite status for all photos when component mounts or photos change
// Uses batch API to reduce N+1 query problem
useEffect(() => {
if (!session?.user?.id) {
setFavoritedPhotos(new Map());
return;
}
const fetchFavoriteStatuses = async () => {
const photoIds = uniquePhotos.map(p => p.id);
if (photoIds.length === 0) {
return;
}
try {
// Batch API call - single request for all photos
const response = await fetch('/api/photos/favorites/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ photoIds }),
});
if (!response.ok) {
throw new Error('Failed to fetch favorite statuses');
}
const data = await response.json();
const favoriteMap = new Map<number, boolean>();
// Process batch results
if (data.results) {
for (const [photoIdStr, isFavorited] of Object.entries(data.results)) {
const photoId = parseInt(photoIdStr, 10);
favoriteMap.set(photoId, isFavorited as boolean);
}
}
setFavoritedPhotos(favoriteMap);
} catch (error) {
console.error('Error fetching batch favorite statuses:', error);
// Fallback: set empty map on error
setFavoritedPhotos(new Map());
}
};
fetchFavoriteStatuses();
}, [uniquePhotos, session?.user?.id, refreshFavoritesKey]);
// Filter out videos for slideshow navigation (only images)
// Note: This is only used for slideshow context, not for navigation
// Memoized to maintain consistent hook order
const imageOnlyPhotos = useMemo(() => {
return uniquePhotos.filter((p) => !isVideo(p));
}, [uniquePhotos]);
const handlePhotoClick = (photoId: number, index: number) => {
const photo = uniquePhotos.find((p) => p.id === photoId);
if (!photo) return;
// Use the full photos list (including videos) for navigation
// This ensures consistent navigation whether clicking a photo or video
const allPhotoIds = uniquePhotos.map((p) => p.id).join(',');
const photoIndex = uniquePhotos.findIndex((p) => p.id === photoId);
if (photoIndex === -1) return;
// Update URL with photo query param while preserving existing params (filters, etc.)
const params = new URLSearchParams(window.location.search);
params.set('photo', photoId.toString());
params.set('photos', allPhotoIds);
params.set('index', photoIndex.toString());
router.push(`/?${params.toString()}`, { scroll: false });
};
const handlePhotoInteraction = (photoId: number, index: number) => {
if (selectionMode && onToggleSelect) {
onToggleSelect(photoId);
return;
}
handlePhotoClick(photoId, index);
};
const resetReportDialog = () => {
setReportDialogPhotoId(null);
setReportDialogComment('');
setReportDialogError(null);
};
const handleUndoReport = async (photoId: number) => {
const reportInfo = reportedPhotos.get(photoId);
const isReported = reportInfo && reportInfo.status === 'pending';
if (!isReported || reportingPhotoId === photoId) {
return;
}
setReportingPhotoId(photoId);
try {
const response = await fetch(`/api/photos/${photoId}/report`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
if (response.status === 401) {
alert('Please sign in to report photos');
} else if (response.status === 403) {
alert('Cannot undo report that has already been reviewed');
} else if (response.status === 404) {
alert('Report not found');
} else {
alert(error.error || 'Failed to undo report');
}
return;
}
const newMap = new Map(reportedPhotos);
newMap.delete(photoId);
setReportedPhotos(newMap);
alert('Report undone successfully.');
} catch (error) {
console.error('Error undoing photo report:', error);
alert('Failed to undo report. Please try again.');
} finally {
setReportingPhotoId(null);
}
};
const handleReportButtonClick = async (e: React.MouseEvent, photoId: number) => {
e.stopPropagation(); // Prevent photo click from firing
if (!session) {
setShowSignInRequiredDialog(true);
return;
}
if (reportingPhotoId === photoId) return; // Already processing
const reportInfo = reportedPhotos.get(photoId);
const isPending = reportInfo && reportInfo.status === 'pending';
const isDismissed = reportInfo && reportInfo.status === 'dismissed';
if (isDismissed) {
alert('This report was dismissed by an administrator and cannot be resubmitted.');
return;
}
if (isPending) {
await handleUndoReport(photoId);
return;
}
setReportDialogPhotoId(photoId);
setReportDialogComment('');
setReportDialogError(null);
};
const handleSubmitReport = async () => {
if (reportDialogPhotoId === null) {
return;
}
const trimmedComment = reportDialogComment.trim();
if (trimmedComment.length > REPORT_COMMENT_MAX_LENGTH) {
setReportDialogError(`Comment must be ${REPORT_COMMENT_MAX_LENGTH} characters or less.`);
return;
}
setReportDialogError(null);
setReportingPhotoId(reportDialogPhotoId);
try {
const response = await fetch(`/api/photos/${reportDialogPhotoId}/report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
comment: trimmedComment.length > 0 ? trimmedComment : null,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => null);
if (response.status === 401) {
setShowSignInRequiredDialog(true);
} else if (response.status === 403) {
alert(error?.error || 'Cannot re-report this photo.');
} else if (response.status === 409) {
alert('You have already reported this photo');
} else if (response.status === 400) {
setReportDialogError(error?.error || 'Invalid comment');
return;
} else {
alert(error?.error || 'Failed to report photo. Please try again.');
}
return;
}
const newMap = new Map(reportedPhotos);
newMap.set(reportDialogPhotoId, { status: 'pending' });
setReportedPhotos(newMap);
const previousReport = reportedPhotos.get(reportDialogPhotoId);
alert(
previousReport && previousReport.status === 'reviewed'
? 'Photo re-reported successfully. Thank you for your report.'
: 'Photo reported successfully. Thank you for your report.'
);
resetReportDialog();
} catch (error) {
console.error('Error reporting photo:', error);
alert('Failed to create report. Please try again.');
} finally {
setReportingPhotoId(null);
}
};
const handleToggleFavorite = async (e: React.MouseEvent, photoId: number) => {
e.stopPropagation(); // Prevent photo click from firing
if (!session) {
setShowSignInRequiredDialog(true);
return;
}
if (favoritingPhotoId === photoId) return; // Already processing
setFavoritingPhotoId(photoId);
try {
const response = await fetch(`/api/photos/${photoId}/favorite`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
if (response.status === 401) {
setShowSignInRequiredDialog(true);
} else {
alert(error.error || 'Failed to toggle favorite');
}
return;
}
const data = await response.json();
const newMap = new Map(favoritedPhotos);
newMap.set(photoId, data.favorited);
setFavoritedPhotos(newMap);
} catch (error) {
console.error('Error toggling favorite:', error);
alert('Failed to toggle favorite. Please try again.');
} finally {
setFavoritingPhotoId(null);
}
};
return (
<TooltipProvider delayDuration={200}>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{uniquePhotos.map((photo, index) => {
const hoveredFaceForPhoto = hoveredFace?.photoId === photo.id ? hoveredFace : null;
const isSelected = selectionMode && selectedPhotoIds.includes(photo.id);
// Determine tooltip text while respecting auth visibility rules
let tooltipText: string = photo.filename; // Default fallback
const isVideoPhoto = isVideo(photo);
if (isVideoPhoto) {
tooltipText = `Video: ${photo.filename}`;
} else if (hoveredFaceForPhoto) {
// Hovering over a specific face
if (hoveredFaceForPhoto.personName) {
// Face is identified - show person name (only if logged in)
tooltipText = isLoggedIn ? hoveredFaceForPhoto.personName : photo.filename;
} else {
// Face is not identified - show "Identify" if user has write access or is not logged in
tooltipText = (!session || hasWriteAccess) ? 'Identify' : photo.filename;
}
} else if (isLoggedIn) {
// Hovering over photo (not a face) - show "People: " + names
const peopleNames = getPeopleNames(photo);
tooltipText = peopleNames.length > 0
? `People: ${peopleNames.join(', ')}`
: photo.filename;
}
return (
<TooltipPrimitive.Root key={photo.id} delayDuration={200}>
<div className="group relative aspect-square">
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handlePhotoInteraction(photo.id, index)}
aria-pressed={isSelected}
className={`relative w-full h-full overflow-hidden rounded-lg bg-gray-100 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-gray-900 ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2' : ''}`}
onMouseMove={(e) => !isVideoPhoto && handleMouseMove(e, photo)}
onMouseLeave={() => setHoveredFace(null)}
>
<Image
src={getImageSrc(photo, { watermark: !isLoggedIn, thumbnail: isVideoPhoto })}
alt={photo.filename}
fill
className="object-contain bg-black/5 transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
priority={index < 9}
onLoad={(e) => !isVideoPhoto && handleImageLoad(photo.id, e.currentTarget)}
/>
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/10" />
{/* Video play icon overlay */}
{isVideoPhoto && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/30 transition-colors">
<div className="rounded-full bg-white/90 p-3 shadow-lg group-hover:bg-white transition-colors">
<Play className="h-6 w-6 text-secondary fill-secondary ml-1" />
</div>
</div>
)}
{selectionMode && (
<>
<div
className={`absolute inset-0 rounded-lg border-2 transition-colors pointer-events-none ${isSelected ? 'border-orange-600' : 'border-transparent'}`}
/>
<div
className={`absolute right-2 top-2 z-10 rounded-full border border-white/50 p-1 text-white transition-colors ${isSelected ? 'bg-orange-600' : 'bg-black/60'}`}
>
<Check className="h-4 w-4" />
</div>
</>
)}
</button>
</TooltipTrigger>
{/* Download Button - Top Left Corner */}
<button
type="button"
onClick={(e) => handleDownloadPhoto(e, photo)}
className="absolute left-2 top-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
aria-label="Download photo"
title="Download photo"
>
<Download className="h-4 w-4" />
</button>
{/* Report Button - Left Bottom Corner - Show always */}
{(() => {
if (!session) {
// Not logged in - show basic report button
return (
<button
type="button"
onClick={(e) => handleReportButtonClick(e, photo.id)}
className="absolute left-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
aria-label="Report inappropriate photo"
title="Report inappropriate photo"
>
<Flag className="h-4 w-4" />
</button>
);
}
// Logged in - show button with status
const reportInfo = reportedPhotos.get(photo.id);
const isReported = reportInfo && reportInfo.status === 'pending';
const isReviewed = reportInfo && reportInfo.status === 'reviewed';
const isDismissed = reportInfo && reportInfo.status === 'dismissed';
let tooltipText: string;
let buttonClass: string;
if (isReported) {
tooltipText = 'Reported as inappropriate. Click to undo';
buttonClass = 'bg-red-600/70 hover:bg-red-600/90';
} else if (isReviewed) {
tooltipText = 'Report reviewed and kept. Click to report again';
buttonClass = 'bg-green-600/70 hover:bg-green-600/90';
} else if (isDismissed) {
tooltipText = 'Report dismissed';
buttonClass = 'bg-gray-600/70 hover:bg-gray-600/90';
} else {
tooltipText = 'Report inappropriate photo';
buttonClass = 'bg-black/50 hover:bg-black/70';
}
return (
<button
type="button"
onClick={(e) => handleReportButtonClick(e, photo.id)}
disabled={reportingPhotoId === photo.id || isDismissed}
className={`absolute left-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed ${buttonClass}`}
aria-label={tooltipText}
title={tooltipText}
>
<Flag className={`h-4 w-4 ${isReported || isReviewed ? 'fill-current' : ''}`} />
</button>
);
})()}
{/* Favorite Button - Right Bottom Corner - Show always */}
{(() => {
if (!session) {
// Not logged in - show basic favorite button
return (
<button
type="button"
onClick={(e) => handleToggleFavorite(e, photo.id)}
className="absolute right-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity bg-black/50 hover:bg-black/70"
aria-label="Add to favorites"
title="Add to favorites (sign in required)"
>
<Heart className="h-4 w-4" />
</button>
);
}
// Logged in - show button with favorite status
const isFavorited = favoritedPhotos.get(photo.id) || false;
return (
<button
type="button"
onClick={(e) => handleToggleFavorite(e, photo.id)}
disabled={favoritingPhotoId === photo.id}
className={`absolute right-2 bottom-2 z-10 p-1.5 rounded-full text-white opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed ${
isFavorited
? 'bg-red-600/70 hover:bg-red-600/90'
: 'bg-black/50 hover:bg-black/70'
}`}
aria-label={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
title={isFavorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart className={`h-4 w-4 ${isFavorited ? 'fill-current' : ''}`} />
</button>
);
})()}
</div>
<TooltipContent
side="bottom"
sideOffset={5}
className="max-w-xs bg-blue-400 text-white z-[9999]"
arrowColor="blue-400"
>
<p className="text-sm font-medium">{tooltipText || photo.filename}</p>
</TooltipContent>
</TooltipPrimitive.Root>
);
})}
</div>
{/* Report Comment Dialog */}
<Dialog
open={reportDialogPhotoId !== null}
onOpenChange={(open) => {
if (!open) {
resetReportDialog();
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Report Photo</DialogTitle>
<DialogDescription>
Optionally include a short comment to help administrators understand the issue.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<label htmlFor="report-comment" className="text-sm font-medium text-secondary">
Comment (optional)
</label>
<textarea
id="report-comment"
value={reportDialogComment}
onChange={(event) => setReportDialogComment(event.target.value)}
maxLength={REPORT_COMMENT_MAX_LENGTH}
className="mt-2 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-900"
rows={4}
placeholder="Add a short note about why this photo should be reviewed..."
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>{`${reportDialogComment.length}/${REPORT_COMMENT_MAX_LENGTH} characters`}</span>
{reportDialogError && <span className="text-red-600">{reportDialogError}</span>}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
resetReportDialog();
}}
>
Cancel
</Button>
<Button
onClick={handleSubmitReport}
disabled={
reportDialogPhotoId === null || reportingPhotoId === reportDialogPhotoId
}
>
{reportDialogPhotoId !== null && reportingPhotoId === reportDialogPhotoId
? 'Reporting...'
: 'Report photo'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Sign In Required Dialog for Report */}
<Dialog open={showSignInRequiredDialog} onOpenChange={setShowSignInRequiredDialog}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Sign In Required</DialogTitle>
<DialogDescription>
You need to be signed in to report photos. Your reports will be reviewed by administrators.
</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={() => setShowSignInRequiredDialog(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Login Dialog */}
<LoginDialog
open={loginDialogOpen}
onOpenChange={(open) => {
setLoginDialogOpen(open);
if (!open) {
setShowRegisteredMessage(false);
}
}}
onSuccess={async () => {
await update();
router.refresh();
setShowSignInRequiredDialog(false);
}}
onOpenRegister={() => {
setLoginDialogOpen(false);
setRegisterDialogOpen(true);
}}
registered={showRegisteredMessage}
callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
/>
{/* Register Dialog */}
<RegisterDialog
open={registerDialogOpen}
onOpenChange={(open) => {
setRegisterDialogOpen(open);
if (!open) {
setShowRegisteredMessage(false);
}
}}
onSuccess={async () => {
await update();
router.refresh();
setShowSignInRequiredDialog(false);
}}
onOpenLogin={() => {
setShowRegisteredMessage(true);
setRegisterDialogOpen(false);
setLoginDialogOpen(true);
}}
callbackUrl={typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/'}
/>
</TooltipProvider>
);
}
+172
View File
@@ -0,0 +1,172 @@
'use client';
import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { Photo, Person } from '@prisma/client';
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { isUrl, getImageSrc } from '@/lib/photo-utils';
interface PhotoWithDetails extends Photo {
faces?: Array<{
person: Person | null;
}>;
photoTags?: Array<{
tag: {
tagName: string;
};
}>;
}
interface PhotoViewerProps {
photo: PhotoWithDetails;
previousId: number | null;
nextId: number | null;
}
export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const { data: session } = useSession();
const isLoggedIn = Boolean(session);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft' && previousId) {
navigateToPhoto(previousId);
} else if (e.key === 'ArrowRight' && nextId) {
navigateToPhoto(nextId);
} else if (e.key === 'Escape') {
router.back();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [previousId, nextId, router]);
const navigateToPhoto = (photoId: number) => {
setLoading(true);
router.push(`/photo/${photoId}`);
};
const handlePrevious = () => {
if (previousId) {
navigateToPhoto(previousId);
}
};
const handleNext = () => {
if (nextId) {
navigateToPhoto(nextId);
}
};
const handleClose = () => {
// Use router.back() to return to the previous page without reloading
// This preserves filters, pagination, and scroll position
router.back();
};
const peopleNames = photo.faces
?.map((face) => face.person)
.filter((person): person is Person => person !== null)
.map((person) => `${person.firstName} ${person.lastName}`.trim()) || [];
const tags = photo.photoTags?.map((pt) => pt.tag.tagName) || [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
{/* Close Button */}
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 z-10 text-white hover:bg-white/20"
onClick={handleClose}
aria-label="Close"
>
<X className="h-6 w-6" />
</Button>
{/* Previous Button */}
{previousId && (
<Button
variant="ghost"
size="icon"
className="absolute left-4 z-10 text-white hover:bg-white/20 disabled:opacity-30"
onClick={handlePrevious}
disabled={loading}
aria-label="Previous photo"
>
<ChevronLeft className="h-8 w-8" />
</Button>
)}
{/* Next Button */}
{nextId && (
<Button
variant="ghost"
size="icon"
className="absolute right-4 z-10 text-white hover:bg-white/20 disabled:opacity-30"
onClick={handleNext}
disabled={loading}
aria-label="Next photo"
>
<ChevronRight className="h-8 w-8" />
</Button>
)}
{/* Photo Container */}
<div className="relative h-full w-full flex items-center justify-center p-4">
{loading ? (
<div className="text-white">Loading...</div>
) : (
<div className="relative h-full w-full max-h-[90vh] max-w-full">
<Image
src={getImageSrc(photo, { watermark: !isLoggedIn })}
alt={photo.filename}
fill
className="object-contain"
priority
unoptimized={!isUrl(photo.path)}
sizes="100vw"
/>
</div>
)}
</div>
{/* Photo Info Overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 text-white">
<div className="container mx-auto">
<h2 className="text-xl font-semibold mb-2">{photo.filename}</h2>
{photo.dateTaken && (
<p className="text-sm text-gray-300 mb-2">
{new Date(photo.dateTaken).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
)}
{peopleNames.length > 0 && (
<p className="text-sm text-gray-300 mb-1">
<span className="font-medium">People: </span>
{peopleNames.join(', ')}
</p>
)}
{tags.length > 0 && (
<p className="text-sm text-gray-300">
<span className="font-medium">Tags: </span>
{tags.join(', ')}
</p>
)}
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,281 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import Link from 'next/link';
import { isValidEmail } from '@/lib/utils';
interface RegisterDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
onOpenLogin?: () => void;
callbackUrl?: string;
}
export function RegisterDialog({
open,
onOpenChange,
onSuccess,
onOpenLogin,
callbackUrl: initialCallbackUrl,
}: RegisterDialogProps) {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = initialCallbackUrl || searchParams.get('callbackUrl') || '/';
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// Clear form when dialog opens
useEffect(() => {
if (open) {
setName('');
setEmail('');
setPassword('');
setConfirmPassword('');
setError('');
setShowPassword(false);
setShowConfirmPassword(false);
}
}, [open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name || name.trim().length === 0) {
setError('Name is required');
return;
}
if (!email || !isValidEmail(email)) {
setError('Please enter a valid email address');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, name }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to create account');
return;
}
// Registration successful - clear form and show success message
setName('');
setEmail('');
setPassword('');
setConfirmPassword('');
setError('');
// Show success state
alert('Account created successfully! Please check your email to confirm your account before signing in.');
onOpenChange(false);
if (onOpenLogin) {
// Open login dialog with registered flag
onOpenLogin();
} else if (onSuccess) {
onSuccess();
} else {
// Redirect to login with registered flag
router.push(`/login?registered=true&callbackUrl=${encodeURIComponent(callbackUrl)}`);
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
// Reset form when closing
setName('');
setEmail('');
setPassword('');
setConfirmPassword('');
setError('');
setShowPassword(false);
setShowConfirmPassword(false);
}
onOpenChange(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create your account</DialogTitle>
<DialogDescription>
Or{' '}
<button
type="button"
className="font-medium text-secondary hover:text-secondary/80"
onClick={() => {
handleOpenChange(false);
if (onOpenLogin) {
onOpenLogin();
}
}}
>
sign in to your existing account
</button>
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-secondary dark:text-gray-300">
Name <span className="text-red-500">*</span>
</label>
<Input
id="name"
name="name"
type="text"
autoComplete="off"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
placeholder="Your full name"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary dark:text-gray-300">
Email address <span className="text-red-500">*</span>
</label>
<Input
id="email"
name="email"
type="email"
autoComplete="off"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary dark:text-gray-300">
Password
</label>
<div className="relative mt-1">
<Input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pr-10"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary hover:text-secondary/80 focus:outline-none"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Must be at least 6 characters
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary dark:text-gray-300">
Confirm Password
</label>
<div className="relative mt-1">
<Input
id="confirmPassword"
name="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pr-10"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary hover:text-secondary/80 focus:outline-none"
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
<DialogFooter>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Creating account...' : 'Create account'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,20 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import { IdleLogoutHandler } from '@/components/IdleLogoutHandler';
export function SessionProviderWrapper({
children,
}: {
children: React.ReactNode;
}) {
return (
<SessionProvider>
<IdleLogoutHandler />
{children}
</SessionProvider>
);
}
@@ -0,0 +1,36 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
export function SimpleHeader() {
return (
<div className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 pb-4 mb-4 border-b">
<div className="mb-4 flex items-center justify-between">
<Link href="/" aria-label="Home">
<Image
src="/logo.png"
alt="PunimTag"
width={300}
height={80}
className="h-20 w-auto cursor-pointer hover:opacity-80 transition-opacity"
priority
/>
</Link>
<div className="flex items-center gap-2">
<UserMenu />
</div>
</div>
<p className="text-lg font-medium text-orange-600 dark:text-orange-500 tracking-wide">
Browse our photo collection
</p>
</div>
);
}
@@ -0,0 +1,334 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Tag as TagModel } from '@prisma/client';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Loader2, Tag as TagIcon, X } from 'lucide-react';
interface TagSelectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
photoIds: number[];
tags: TagModel[];
onSuccess?: () => void;
}
export function TagSelectionDialog({
open,
onOpenChange,
photoIds,
tags,
onSuccess,
}: TagSelectionDialogProps) {
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [customTags, setCustomTags] = useState<string[]>([]);
const [customTagInput, setCustomTagInput] = useState('');
const [notes, setNotes] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const filteredTags = useMemo(() => {
if (!searchQuery.trim()) {
return tags;
}
const query = searchQuery.toLowerCase();
return tags.filter((tag) => tag.tagName.toLowerCase().includes(query));
}, [searchQuery, tags]);
useEffect(() => {
if (!open) {
setSelectedTagIds([]);
setSearchQuery('');
setCustomTags([]);
setCustomTagInput('');
setNotes('');
setError(null);
}
}, [open]);
useEffect(() => {
setSelectedTagIds((prev) =>
prev.filter((id) => tags.some((tag) => tag.id === id))
);
}, [tags]);
const toggleTagSelection = (tagId: number) => {
setSelectedTagIds((prev) =>
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]
);
};
const canSubmit =
photoIds.length > 0 &&
(selectedTagIds.length > 0 ||
customTags.length > 0 ||
customTagInput.trim().length > 0);
const normalizeTagName = (value: string) => value.trim().replace(/\s+/g, ' ');
const addCustomTag = () => {
const candidate = normalizeTagName(customTagInput);
if (!candidate) {
setCustomTagInput('');
return;
}
const exists = customTags.some(
(tag) => tag.toLowerCase() === candidate.toLowerCase()
);
if (!exists) {
setCustomTags((prev) => [...prev, candidate]);
}
setCustomTagInput('');
};
const removeCustomTag = (tagName: string) => {
setCustomTags((prev) =>
prev.filter((tag) => tag.toLowerCase() !== tagName.toLowerCase())
);
};
const handleSubmit = async () => {
setError(null);
if (photoIds.length === 0) {
setError('Select at least one photo before tagging.');
return;
}
const normalizedInput = normalizeTagName(customTagInput);
const proposedTags = [
...customTags,
...(normalizedInput ? [normalizedInput] : []),
];
const uniqueNewTags = Array.from(
new Map(
proposedTags.map((tag) => [tag.toLowerCase(), tag])
).values()
);
const payload = {
photoIds,
tagIds: selectedTagIds.length > 0 ? selectedTagIds : undefined,
newTagNames: uniqueNewTags.length > 0 ? uniqueNewTags : undefined,
notes: notes.trim() || undefined,
};
try {
setIsSubmitting(true);
const response = await fetch('/api/photos/tag-linkages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to submit tag linkages');
}
alert(
data.message ||
'Tag submissions sent for approval. An administrator will review them soon.'
);
onOpenChange(false);
onSuccess?.();
setCustomTags([]);
setCustomTagInput('');
} catch (submissionError: any) {
setError(submissionError.message || 'Failed to submit tag linkages');
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Tag selected photos</DialogTitle>
<DialogDescription>
Choose existing tags or propose a new tag. Your request goes to the
pending queue for admin approval before it appears on the site.
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="rounded-md bg-muted/40 p-3 text-sm text-muted-foreground">
Tagging{' '}
<span className="font-medium text-foreground">
{photoIds.length}
</span>{' '}
photo{photoIds.length === 1 ? '' : 's'}. Pending linkages require
administrator approval.
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Choose existing tags</label>
<Input
placeholder="Start typing to filter tags..."
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
<div className="max-h-52 overflow-y-auto rounded-md border p-2 space-y-1">
{filteredTags.length === 0 ? (
<p className="text-sm text-muted-foreground px-1">
No tags match your search.
</p>
) : (
filteredTags.map((tag) => (
<label
key={tag.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/60"
>
<Checkbox
checked={selectedTagIds.includes(tag.id)}
onCheckedChange={() => toggleTagSelection(tag.id)}
/>
<span className="text-sm">{tag.tagName}</span>
</label>
))
)}
</div>
{selectedTagIds.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{selectedTagIds.map((id) => {
const tag = tags.find((item) => item.id === id);
if (!tag) return null;
return (
<Badge
key={id}
variant="secondary"
className="flex items-center gap-1"
>
<TagIcon className="h-3 w-3" />
{tag.tagName}
</Badge>
);
})}
</div>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Add a new tag
</label>
<div className="flex flex-col gap-2">
<Input
placeholder="Enter a new tag name, press Enter to add"
value={customTagInput}
onChange={(event) => setCustomTagInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustomTag();
}
}}
/>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
onClick={addCustomTag}
>
Add
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setCustomTags([]);
setCustomTagInput('');
}}
disabled={customTags.length === 0 && !customTagInput.trim()}
>
Clear
</Button>
</div>
</div>
{customTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{customTags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
<TagIcon className="h-3 w-3" />
{tag}
<button
type="button"
className="ml-1 text-xs text-muted-foreground hover:text-foreground"
onClick={() => removeCustomTag(tag)}
aria-label={`Remove ${tag}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
Add as many missing tags as you need. Admins will create them during
review.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Notes for admins (optional)
</label>
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
rows={3}
placeholder="Add any additional context to help admins approve faster"
/>
</div>
{error && (
<p className="text-sm text-red-500" role="alert">
{error}
</p>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
'Submit for review'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+169
View File
@@ -0,0 +1,169 @@
'use client';
import { useState } from 'react';
import { useSession, signOut } from 'next-auth/react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { User, Upload, Users, LogIn, UserPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { LoginDialog } from '@/components/LoginDialog';
import { RegisterDialog } from '@/components/RegisterDialog';
import { ManageUsersPageClient } from '@/app/admin/users/ManageUsersPageClient';
function UserMenu() {
const { data: session, status } = useSession();
const router = useRouter();
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [manageUsersOpen, setManageUsersOpen] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const handleSignOut = async () => {
await signOut({ callbackUrl: '/' });
};
if (status === 'loading') {
return <div className="h-9 w-9 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />;
}
if (session?.user) {
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
asChild
className="bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800"
>
<Link href="/upload" aria-label="Upload Photos">
<Upload className="h-5 w-5" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Upload your own photos</p>
</TooltipContent>
</Tooltip>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full"
aria-label="Account menu"
>
<User className="h-5 w-5 text-orange-600" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2 z-[110]" align="end">
<div className="space-y-1">
<div className="px-2 py-1.5">
<p className="text-sm font-medium text-secondary">
{session.user.name || 'User'}
</p>
<p className="text-xs text-muted-foreground">
{session.user.email}
</p>
</div>
<div className="border-t pt-1">
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
router.push('/upload');
}}
>
<Upload className="mr-2 h-4 w-4" />
Upload Photos
</Button>
{session.user.isAdmin && (
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
setManageUsersOpen(true);
}}
>
<Users className="mr-2 h-4 w-4" />
Manage Users
</Button>
)}
<Button
variant="ghost"
className="w-full justify-start text-sm text-secondary hover:text-secondary hover:bg-secondary/10"
onClick={() => {
setPopoverOpen(false);
handleSignOut();
}}
>
Sign out
</Button>
</div>
</div>
</PopoverContent>
</Popover>
{manageUsersOpen && (
<ManageUsersPageClient onClose={() => setManageUsersOpen(false)} />
)}
</>
);
}
return (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setLoginDialogOpen(true)}
className="flex items-center gap-2"
>
<LogIn className="h-4 w-4" />
<span className="hidden sm:inline">Sign in</span>
</Button>
<Button
size="sm"
onClick={() => setRegisterDialogOpen(true)}
className="flex items-center gap-2"
>
<UserPlus className="h-4 w-4" />
<span className="hidden sm:inline">Sign up</span>
</Button>
<LoginDialog
open={loginDialogOpen}
onOpenChange={(open) => {
setLoginDialogOpen(open);
}}
onOpenRegister={() => {
setLoginDialogOpen(false);
setRegisterDialogOpen(true);
}}
/>
<RegisterDialog
open={registerDialogOpen}
onOpenChange={(open) => {
setRegisterDialogOpen(open);
}}
onOpenLogin={() => {
setRegisterDialogOpen(false);
setLoginDialogOpen(true);
}}
/>
</>
);
}
export default UserMenu;
@@ -0,0 +1,92 @@
'use client';
import { useState } from 'react';
import { Person, Tag } from '@prisma/client';
import { FilterPanel, SearchFilters } from './FilterPanel';
import { Button } from '@/components/ui/button';
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface CollapsibleSearchProps {
people: Person[];
tags: Tag[];
filters: SearchFilters;
onFiltersChange: (filters: SearchFilters) => void;
}
export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: CollapsibleSearchProps) {
const [isExpanded, setIsExpanded] = useState(true);
const hasActiveFilters =
filters.people.length > 0 ||
filters.tags.length > 0 ||
filters.dateFrom ||
filters.dateTo;
return (
<div
className={cn(
'flex flex-col border-r bg-card transition-all duration-300 sticky top-0 self-start',
isExpanded ? 'w-80' : 'w-16',
'h-[calc(100vh-8rem)]'
)}
>
{/* Collapse/Expand Button */}
<div className="flex items-center justify-between border-b p-4 flex-shrink-0">
{isExpanded ? (
<>
<div className="flex items-center gap-2">
<Search className="h-4 w-4" />
<span className="font-medium text-secondary">Search & Filter</span>
{hasActiveFilters && (
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
{[
filters.people.length,
filters.tags.length,
filters.dateFrom || filters.dateTo ? 1 : 0,
].reduce((a, b) => a + b, 0)}
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(false)}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</>
) : (
<div className="flex items-center justify-center w-full">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(true)}
className="h-8 w-8 p-0 relative"
title="Expand search"
>
<ChevronRight className="h-4 w-4" />
{hasActiveFilters && (
<span className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-primary border-2 border-card" />
)}
</Button>
</div>
)}
</div>
{/* Expanded Filter Panel */}
{isExpanded && (
<div className="flex-1 overflow-y-auto min-h-0">
<FilterPanel
people={people}
tags={tags}
filters={filters}
onFiltersChange={onFiltersChange}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,182 @@
'use client';
import { useState } from 'react';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { CalendarIcon, X } from 'lucide-react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
interface DateRangeFilterProps {
dateFrom?: Date;
dateTo?: Date;
onDateChange: (dateFrom?: Date, dateTo?: Date) => void;
}
const datePresets = [
{ label: 'Today', getDates: () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return { from: today, to: new Date() };
}},
{ label: 'This Week', getDates: () => {
const today = new Date();
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay());
weekStart.setHours(0, 0, 0, 0);
return { from: weekStart, to: new Date() };
}},
{ label: 'This Month', getDates: () => {
const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
return { from: monthStart, to: new Date() };
}},
{ label: 'This Year', getDates: () => {
const today = new Date();
const yearStart = new Date(today.getFullYear(), 0, 1);
return { from: yearStart, to: new Date() };
}},
];
export function DateRangeFilter({ dateFrom, dateTo, onDateChange }: DateRangeFilterProps) {
const [open, setOpen] = useState(false);
const applyPreset = (preset: typeof datePresets[0]) => {
const { from, to } = preset.getDates();
onDateChange(from, to);
setOpen(false);
};
const clearDates = () => {
onDateChange(undefined, undefined);
};
return (
<div className="space-y-2">
<label className="text-sm font-medium text-secondary">Date Range</label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!dateFrom && !dateTo && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateFrom && dateTo ? (
<>
{format(dateFrom, 'MMM d, yyyy')} - {format(dateTo, 'MMM d, yyyy')}
</>
) : (
'Select date range...'
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-3 space-y-2">
<div className="space-y-1">
<p className="text-sm font-medium text-secondary">Quick Presets</p>
<div className="flex flex-wrap gap-2">
{datePresets.map((preset) => (
<Button
key={preset.label}
variant="outline"
size="sm"
onClick={() => applyPreset(preset)}
className="text-xs"
>
{preset.label}
</Button>
))}
</div>
</div>
<div className="border-t pt-2">
<p className="text-sm font-medium text-secondary mb-2">Custom Range</p>
<Calendar
mode="range"
captionLayout="dropdown"
fromYear={1900}
toYear={new Date().getFullYear() + 10}
selected={{
from: dateFrom,
to: dateTo,
}}
onSelect={(range: { from?: Date; to?: Date } | undefined) => {
if (!range) {
onDateChange(undefined, undefined);
return;
}
// If both from and to are set, check if they're different dates
if (range.from && range.to) {
// Check if dates are on the same day
const fromDate = new Date(range.from);
fromDate.setHours(0, 0, 0, 0);
const toDate = new Date(range.to);
toDate.setHours(0, 0, 0, 0);
const sameDay = fromDate.getTime() === toDate.getTime();
if (!sameDay) {
// Valid range with different dates - complete selection and close
onDateChange(range.from, range.to);
setOpen(false);
} else {
// Same day - treat as "from" only, keep popover open for "to" selection
onDateChange(range.from, undefined);
}
} else if (range.from) {
// Only "from" is selected - keep popover open for "to" selection
onDateChange(range.from, undefined);
} else if (range.to) {
// Only "to" is selected (shouldn't happen in range mode, but handle it)
onDateChange(undefined, range.to);
}
}}
numberOfMonths={2}
/>
</div>
{(dateFrom || dateTo) && (
<div className="border-t pt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
clearDates();
setOpen(false);
}}
className="w-full text-xs"
>
<X className="mr-2 h-3 w-3" />
Clear Dates
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
{(dateFrom || dateTo) && (
<Badge variant="secondary" className="flex items-center gap-1 w-fit">
{dateFrom && dateTo ? (
<>
{format(dateFrom, 'MMM d')} - {format(dateTo, 'MMM d, yyyy')}
</>
) : dateFrom ? (
`From ${format(dateFrom, 'MMM d, yyyy')}`
) : (
`Until ${format(dateTo!, 'MMM d, yyyy')}`
)}
<button
onClick={clearDates}
className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
>
<X className="h-3 w-3" />
</button>
</Badge>
)}
</div>
);
}
@@ -0,0 +1,34 @@
'use client';
import { Checkbox } from '@/components/ui/checkbox';
import { Heart } from 'lucide-react';
interface FavoritesFilterProps {
value: boolean;
onChange: (value: boolean) => void;
disabled?: boolean;
}
export function FavoritesFilter({ value, onChange, disabled }: FavoritesFilterProps) {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-secondary">Favorites</label>
<div className="flex items-center space-x-2">
<Checkbox
id="favorites-only"
checked={value}
onCheckedChange={(checked) => onChange(checked === true)}
disabled={disabled}
/>
<label
htmlFor="favorites-only"
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<Heart className="h-4 w-4" />
Show favorites only
</label>
</div>
</div>
);
}
@@ -0,0 +1,115 @@
'use client';
import { Person, Tag } from '@prisma/client';
import { useSession } from 'next-auth/react';
import { PeopleFilter } from './PeopleFilter';
import { DateRangeFilter } from './DateRangeFilter';
import { TagFilter } from './TagFilter';
import { MediaTypeFilter } from './MediaTypeFilter';
import { FavoritesFilter } from './FavoritesFilter';
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
export interface SearchFilters {
people: number[];
peopleMode?: 'any' | 'all';
tags: number[];
tagsMode?: 'any' | 'all';
dateFrom?: Date;
dateTo?: Date;
mediaType?: 'all' | 'photos' | 'videos';
favoritesOnly?: boolean;
}
interface FilterPanelProps {
people: Person[];
tags: Tag[];
filters: SearchFilters;
onFiltersChange: (filters: SearchFilters) => void;
}
export function FilterPanel({ people, tags, filters, onFiltersChange }: FilterPanelProps) {
const { data: session } = useSession();
const isLoggedIn = Boolean(session);
const updateFilters = (updates: Partial<SearchFilters>) => {
onFiltersChange({ ...filters, ...updates });
};
const clearAllFilters = () => {
onFiltersChange({
people: [],
peopleMode: 'any',
tags: [],
tagsMode: 'any',
dateFrom: undefined,
dateTo: undefined,
mediaType: 'all',
favoritesOnly: false,
});
};
const hasActiveFilters =
filters.people.length > 0 ||
filters.tags.length > 0 ||
filters.dateFrom ||
filters.dateTo ||
(filters.mediaType && filters.mediaType !== 'all') ||
filters.favoritesOnly === true;
return (
<div className="space-y-6 p-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-secondary">Filters</h2>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="h-8"
>
<X className="mr-2 h-4 w-4" />
Clear All
</Button>
)}
</div>
{isLoggedIn && (
<PeopleFilter
people={people}
selected={filters.people}
mode={filters.peopleMode || 'any'}
onSelectionChange={(selected) => updateFilters({ people: selected })}
onModeChange={(mode) => updateFilters({ peopleMode: mode })}
/>
)}
<MediaTypeFilter
value={filters.mediaType || 'all'}
onChange={(value) => updateFilters({ mediaType: value })}
/>
{isLoggedIn && (
<FavoritesFilter
value={filters.favoritesOnly || false}
onChange={(value) => updateFilters({ favoritesOnly: value })}
/>
)}
<DateRangeFilter
dateFrom={filters.dateFrom}
dateTo={filters.dateTo}
onDateChange={(dateFrom, dateTo) => updateFilters({ dateFrom, dateTo })}
/>
<TagFilter
tags={tags}
selected={filters.tags}
mode={filters.tagsMode || 'any'}
onSelectionChange={(selected) => updateFilters({ tags: selected })}
onModeChange={(mode) => updateFilters({ tagsMode: mode })}
/>
</div>
);
}
@@ -0,0 +1,30 @@
'use client';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
export type MediaType = 'all' | 'photos' | 'videos';
interface MediaTypeFilterProps {
value: MediaType;
onChange: (value: MediaType) => void;
}
export function MediaTypeFilter({ value, onChange }: MediaTypeFilterProps) {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-secondary">Media type</label>
<Select value={value} onValueChange={(val) => onChange(val as MediaType)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="photos">Photos</SelectItem>
<SelectItem value="videos">Videos</SelectItem>
</SelectContent>
</Select>
</div>
);
}
@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import { Person } from '@prisma/client';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Search, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface PeopleFilterProps {
people: Person[];
selected: number[];
mode: 'any' | 'all';
onSelectionChange: (selected: number[]) => void;
onModeChange: (mode: 'any' | 'all') => void;
}
export function PeopleFilter({ people, selected, mode, onSelectionChange, onModeChange }: PeopleFilterProps) {
const [searchQuery, setSearchQuery] = useState('');
const [open, setOpen] = useState(false);
const filteredPeople = people.filter((person) => {
const fullName = `${person.firstName} ${person.lastName}`.toLowerCase();
return fullName.includes(searchQuery.toLowerCase());
});
const togglePerson = (personId: number) => {
if (selected.includes(personId)) {
onSelectionChange(selected.filter((id) => id !== personId));
} else {
onSelectionChange([...selected, personId]);
}
};
const selectedPeople = people.filter((p) => selected.includes(p.id));
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-secondary">People</label>
{selected.length > 1 && (
<Select value={mode} onValueChange={(value) => onModeChange(value as 'any' | 'all')}>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
)}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
<Search className="mr-2 h-4 w-4" />
{selected.length === 0 ? 'Select people...' : `${selected.length} selected`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<div className="p-2">
<Input
placeholder="Search people..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="mb-2"
/>
<div className="max-h-[300px] overflow-y-auto">
{filteredPeople.length === 0 ? (
<p className="p-2 text-sm text-gray-500">No people found</p>
) : (
<div className="space-y-1">
{filteredPeople.map((person) => {
const isSelected = selected.includes(person.id);
return (
<div
key={person.id}
className="flex items-center space-x-2 rounded-md p-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
onClick={() => togglePerson(person.id)}
>
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => togglePerson(person.id)}
/>
</span>
<label className="flex-1 cursor-pointer text-sm">
{person.firstName} {person.lastName}
</label>
</div>
);
})}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
{selectedPeople.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedPeople.map((person) => (
<Badge
key={person.id}
variant="secondary"
className="flex items-center gap-1"
>
{person.firstName} {person.lastName}
<button
onClick={() => togglePerson(person.id)}
className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,38 @@
'use client';
import { useState, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Search } from 'lucide-react';
interface SearchBarProps {
onSearch: (query: string) => void;
placeholder?: string;
defaultValue?: string;
}
export function SearchBar({ onSearch, placeholder = 'Search photos...', defaultValue = '' }: SearchBarProps) {
const [query, setQuery] = useState(defaultValue);
// Debounce search
useEffect(() => {
const timer = setTimeout(() => {
onSearch(query);
}, 300);
return () => clearTimeout(timer);
}, [query, onSearch]);
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-10"
/>
</div>
);
}
@@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import { Tag } from '@prisma/client';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Search, X } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface TagFilterProps {
tags: Tag[];
selected: number[];
mode: 'any' | 'all';
onSelectionChange: (selected: number[]) => void;
onModeChange: (mode: 'any' | 'all') => void;
}
export function TagFilter({ tags, selected, mode, onSelectionChange, onModeChange }: TagFilterProps) {
const [searchQuery, setSearchQuery] = useState('');
const [open, setOpen] = useState(false);
const filteredTags = tags.filter((tag) => {
const tagName = tag.tagName || tag.tag_name || '';
return tagName.toLowerCase().includes(searchQuery.toLowerCase());
});
const toggleTag = (tagId: number) => {
if (selected.includes(tagId)) {
onSelectionChange(selected.filter((id) => id !== tagId));
} else {
onSelectionChange([...selected, tagId]);
}
};
const selectedTags = tags.filter((t) => selected.includes(t.id));
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-secondary">Tags</label>
{selected.length > 1 && (
<Select value={mode} onValueChange={(value) => onModeChange(value as 'any' | 'all')}>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
)}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
<Search className="mr-2 h-4 w-4" />
{selected.length === 0 ? 'Select tags...' : `${selected.length} selected`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<div className="p-2">
<Input
placeholder="Search tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="mb-2"
/>
<div className="max-h-[300px] overflow-y-auto">
{filteredTags.length === 0 ? (
<p className="p-2 text-sm text-gray-500">No tags found</p>
) : (
<div className="space-y-1">
{filteredTags.map((tag) => {
const isSelected = selected.includes(tag.id);
return (
<div
key={tag.id}
className="flex items-center space-x-2 rounded-md p-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
onClick={() => toggleTag(tag.id)}
>
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleTag(tag.id)}
/>
</span>
<label className="flex-1 cursor-pointer text-sm">
{tag.tagName || tag.tag_name || 'Unnamed Tag'}
</label>
</div>
);
})}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedTags.map((tag) => (
<Badge
key={tag.id}
variant="secondary"
className="flex items-center gap-1"
>
{tag.tagName || tag.tag_name || 'Unnamed Tag'}
<button
onClick={() => toggleTag(tag.id)}
className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+60
View File
@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+216
View File
@@ -0,0 +1,216 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+144
View File
@@ -0,0 +1,144 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
const DialogContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
overlayClassName?: string
}
>(({ className, children, showCloseButton = true, overlayClassName, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
})
DialogContent.displayName = "DialogContent"
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
+48
View File
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+187
View File
@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+69
View File
@@ -0,0 +1,69 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
delayDuration = 300,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root> & {
delayDuration?: number;
}) {
return (
<TooltipProvider delayDuration={delayDuration}>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
arrowColor = 'orange-600',
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
arrowColor?: string;
}) {
const arrowBgClass = arrowColor === 'blue-400' ? 'bg-blue-400 fill-blue-400' : 'bg-orange-600 fill-orange-600';
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-orange-600 text-white animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className={cn(arrowBgClass, "z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]")} />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }