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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,666 @@
'use client';
import { useState, useEffect, useCallback } 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 { Trash2, Plus, Edit2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { isValidEmail } from '@/lib/utils';
interface User {
id: number;
email: string;
name: string | null;
isAdmin: boolean;
hasWriteAccess: boolean;
isActive?: boolean;
createdAt: string;
updatedAt: string;
}
type UserStatusFilter = 'all' | 'active' | 'inactive';
export function ManageUsersContent() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<UserStatusFilter>('active');
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
// Form state
const [formData, setFormData] = useState({
email: '',
password: '',
name: '',
hasWriteAccess: false,
isAdmin: false,
isActive: true,
});
// Fetch users
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
setError(null);
console.log('[ManageUsers] Fetching users with filter:', statusFilter);
const url = statusFilter === 'all'
? '/api/users?status=all'
: statusFilter === 'inactive'
? '/api/users?status=inactive'
: '/api/users?status=active';
console.log('[ManageUsers] Fetching from URL:', url);
const response = await fetch(url, {
credentials: 'include', // Ensure cookies are sent
});
console.log('[ManageUsers] Response status:', response.status, response.statusText);
let data;
const contentType = response.headers.get('content-type');
console.log('[ManageUsers] Content-Type:', contentType);
try {
const text = await response.text();
console.log('[ManageUsers] Response text:', text);
data = text ? JSON.parse(text) : {};
} catch (parseError) {
console.error('[ManageUsers] Failed to parse response:', parseError);
throw new Error(`Server error (${response.status}): Invalid JSON response`);
}
console.log('[ManageUsers] Parsed data:', data);
if (!response.ok) {
const errorMsg = data?.error || data?.details || data?.message || `HTTP ${response.status}: ${response.statusText}`;
console.error('[ManageUsers] API Error:', {
status: response.status,
statusText: response.statusText,
data
});
throw new Error(errorMsg);
}
if (!data.users) {
console.warn('[ManageUsers] Response missing users array:', data);
setUsers([]);
} else {
console.log('[ManageUsers] Successfully loaded', data.users.length, 'users');
setUsers(data.users);
}
} catch (err: any) {
console.error('[ManageUsers] Error fetching users:', err);
setError(err.message || 'Failed to load users');
} finally {
setLoading(false);
}
}, [statusFilter]);
// Debug: Log when statusFilter changes
useEffect(() => {
console.log('[ManageUsers] statusFilter state changed to:', statusFilter);
}, [statusFilter]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
// Handle add user
const handleAddUser = async () => {
try {
setError(null);
// Client-side validation
if (!formData.name || formData.name.trim().length === 0) {
setError('Name is required');
return;
}
if (!formData.email || !isValidEmail(formData.email)) {
setError('Please enter a valid email address');
return;
}
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create user');
}
setIsAddDialogOpen(false);
setFormData({ email: '', password: '', name: '', hasWriteAccess: false, isAdmin: false, isActive: true });
fetchUsers();
} catch (err: any) {
setError(err.message || 'Failed to create user');
}
};
// Handle edit user
const handleEditUser = async () => {
if (!editingUser) return;
try {
setError(null);
// Client-side validation
if (!formData.name || formData.name.trim().length === 0) {
setError('Name is required');
return;
}
const updateData: any = {};
if (formData.email !== editingUser.email) {
updateData.email = formData.email;
}
if (formData.name !== editingUser.name) {
updateData.name = formData.name;
}
if (formData.password) {
updateData.password = formData.password;
}
if (formData.hasWriteAccess !== editingUser.hasWriteAccess) {
updateData.hasWriteAccess = formData.hasWriteAccess;
}
if (formData.isAdmin !== editingUser.isAdmin) {
updateData.isAdmin = formData.isAdmin;
}
// Treat undefined/null as true, so only check if explicitly false
const currentIsActive = editingUser.isActive !== false;
if (formData.isActive !== currentIsActive) {
updateData.isActive = formData.isActive;
}
if (Object.keys(updateData).length === 0) {
setIsEditDialogOpen(false);
return;
}
const response = await fetch(`/api/users/${editingUser.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update user');
}
setIsEditDialogOpen(false);
setEditingUser(null);
setFormData({ email: '', password: '', name: '', hasWriteAccess: false, isAdmin: false, isActive: true });
fetchUsers();
} catch (err: any) {
setError(err.message || 'Failed to update user');
}
};
// Handle delete user
const handleDeleteUser = async () => {
if (!userToDelete) return;
try {
setError(null);
setSuccessMessage(null);
const response = await fetch(`/api/users/${userToDelete.id}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete user');
}
const data = await response.json();
setDeleteConfirmOpen(false);
setUserToDelete(null);
// Check if user was deactivated instead of deleted
if (data.deactivated) {
setSuccessMessage(
`User ${userToDelete.email} was deactivated (not deleted) because they have ${data.relatedRecords?.pendingLinkages || 0} pending linkages, ${data.relatedRecords?.photoFavorites || 0} favorites, and other related records.`
);
} else {
setSuccessMessage(`User ${userToDelete.email} was deleted successfully.`);
}
// Clear success message after 5 seconds
setTimeout(() => setSuccessMessage(null), 5000);
fetchUsers();
} catch (err: any) {
setError(err.message || 'Failed to delete user');
}
};
// Open edit dialog
const openEditDialog = (user: User) => {
setEditingUser(user);
setFormData({
email: user.email,
password: '',
name: user.name || '',
hasWriteAccess: user.hasWriteAccess,
isAdmin: user.isAdmin,
isActive: user.isActive !== false, // Treat undefined/null as true
});
setIsEditDialogOpen(true);
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8">
<div className="text-center">Loading users...</div>
</div>
);
}
return (
<div className="container mx-auto max-w-7xl">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Manage Users</h1>
<p className="text-muted-foreground mt-1">
Manage user accounts and permissions
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label htmlFor="status-filter" className="text-sm font-medium">
User Status:
</label>
<Select
value={statusFilter}
onValueChange={(value) => {
console.log('[ManageUsers] Filter changed to:', value);
setStatusFilter(value as UserStatusFilter);
}}
>
<SelectTrigger id="status-filter" className="w-[150px]">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent className="z-[120]">
<SelectItem value="all">All</SelectItem>
<SelectItem value="active">Active only</SelectItem>
<SelectItem value="inactive">Inactive only</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={() => setIsAddDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add User
</Button>
</div>
</div>
{error && (
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{successMessage && (
<div className="mb-4 rounded-md bg-green-50 p-4 text-green-800 dark:bg-green-900/20 dark:text-green-400">
{successMessage}
</div>
)}
<div className="rounded-lg border bg-card">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="px-4 py-3 text-left text-sm font-medium">Email</th>
<th className="px-4 py-3 text-left text-sm font-medium">Name</th>
<th className="px-4 py-3 text-left text-sm font-medium">Status</th>
<th className="px-4 py-3 text-left text-sm font-medium">Role</th>
<th className="px-4 py-3 text-left text-sm font-medium">Write Access</th>
<th className="px-4 py-3 text-left text-sm font-medium">Created</th>
<th className="px-4 py-3 text-right text-sm font-medium">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b">
<td className="px-4 py-3">{user.email}</td>
<td className="px-4 py-3">{user.name || '-'}</td>
<td className="px-4 py-3">
{user.isActive === false ? (
<Badge variant="outline" className="border-red-300 text-red-700 dark:border-red-800 dark:text-red-400">Inactive</Badge>
) : (
<Badge variant="outline" className="border-green-300 text-green-700 dark:border-green-800 dark:text-green-400">Active</Badge>
)}
</td>
<td className="px-4 py-3">
{user.isAdmin ? (
<Badge variant="outline" className="border-blue-300 text-blue-700 dark:border-blue-800 dark:text-blue-400">Admin</Badge>
) : (
<Badge variant="outline" className="border-gray-300 text-gray-700 dark:border-gray-600 dark:text-gray-400">User</Badge>
)}
</td>
<td className="px-4 py-3">
<span className="text-sm">
{user.hasWriteAccess ? 'Yes' : 'No'}
</span>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => openEditDialog(user)}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setUserToDelete(user);
setDeleteConfirmOpen(true);
}}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Add User Dialog */}
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent className="z-[110]" overlayClassName="z-[105]">
<DialogHeader>
<DialogTitle>Add New User</DialogTitle>
<DialogDescription>
Create a new user account. Write access can be granted later.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<label htmlFor="add-email" className="text-sm font-medium">
Email <span className="text-red-500">*</span>
</label>
<Input
id="add-email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
placeholder="user@example.com"
/>
</div>
<div className="grid gap-2">
<label htmlFor="add-password" className="text-sm font-medium">
Password <span className="text-red-500">*</span>
</label>
<Input
id="add-password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
placeholder="Minimum 6 characters"
/>
</div>
<div className="grid gap-2">
<label htmlFor="add-name" className="text-sm font-medium">
Name <span className="text-red-500">*</span>
</label>
<Input
id="add-name"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="Enter full name"
required
/>
</div>
<div className="grid gap-2">
<label htmlFor="add-role" className="text-sm font-medium">
Role <span className="text-red-500">*</span>
</label>
<Select
value={formData.isAdmin ? 'admin' : 'user'}
onValueChange={(value) =>
setFormData({
...formData,
isAdmin: value === 'admin',
hasWriteAccess: value === 'admin' ? true : formData.hasWriteAccess
})
}
>
<SelectTrigger id="add-role" className="w-full">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="add-write-access"
checked={formData.hasWriteAccess}
onCheckedChange={(checked) =>
setFormData({ ...formData, hasWriteAccess: !!checked })
}
/>
<label
htmlFor="add-write-access"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Grant Write Access
</label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsAddDialogOpen(false);
setFormData({ email: '', password: '', name: '', hasWriteAccess: false, isAdmin: false, isActive: true });
}}
>
Cancel
</Button>
<Button onClick={handleAddUser}>Create User</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit User Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="z-[110]" overlayClassName="z-[105]">
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogDescription>
Update user information. Leave password blank to keep current password.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<label htmlFor="edit-email" className="text-sm font-medium">
Email <span className="text-red-500">*</span>
</label>
<Input
id="edit-email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
placeholder="user@example.com"
/>
</div>
<div className="grid gap-2">
<label htmlFor="edit-password" className="text-sm font-medium">
New Password <span className="text-gray-500 font-normal">(leave empty to keep current)</span>
</label>
<Input
id="edit-password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
placeholder="Leave blank to keep current password"
/>
</div>
<div className="grid gap-2">
<label htmlFor="edit-name" className="text-sm font-medium">
Name <span className="text-red-500">*</span>
</label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder="Enter full name"
required
/>
</div>
<div className="grid gap-2">
<label htmlFor="edit-role" className="text-sm font-medium">
Role <span className="text-red-500">*</span>
</label>
<Select
value={formData.isAdmin ? 'admin' : 'user'}
onValueChange={(value) =>
setFormData({
...formData,
isAdmin: value === 'admin',
hasWriteAccess: value === 'admin' ? true : formData.hasWriteAccess
})
}
>
<SelectTrigger id="edit-role" className="w-full">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="edit-write-access"
checked={formData.hasWriteAccess}
onCheckedChange={(checked) =>
setFormData({ ...formData, hasWriteAccess: !!checked })
}
/>
<label
htmlFor="edit-write-access"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Grant Write Access
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="edit-active"
checked={formData.isActive}
onCheckedChange={(checked) =>
setFormData({ ...formData, isActive: !!checked })
}
/>
<label
htmlFor="edit-active"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Active
</label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsEditDialogOpen(false);
setEditingUser(null);
setFormData({ email: '', password: '', name: '', hasWriteAccess: false, isAdmin: false, isActive: true });
}}
>
Cancel
</Button>
<Button onClick={handleEditUser}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="z-[110]" overlayClassName="z-[105]">
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
<DialogDescription>
Are you sure you want to delete {userToDelete?.email}? This action
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDeleteConfirmOpen(false);
setUserToDelete(null);
}}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteUser}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,84 @@
'use client';
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ManageUsersContent } from './ManageUsersContent';
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
interface ManageUsersPageClientProps {
onClose?: () => void;
}
export function ManageUsersPageClient({ onClose }: ManageUsersPageClientProps) {
const handleClose = () => {
if (onClose) {
onClose();
}
};
useEffect(() => {
// Prevent body scroll when overlay is open
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = 'unset';
};
}, []);
const overlayContent = (
<div className="fixed inset-0 z-[100] bg-background overflow-y-auto">
<div className="w-full px-4 py-8">
{/* Close button */}
<div className="mb-4 flex items-center justify-end">
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-9 w-9"
aria-label="Close manage users"
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Header */}
<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>
{/* Manage Users content */}
<div className="mt-8">
<ManageUsersContent />
</div>
</div>
</div>
);
// Render in portal to ensure it's above everything
if (typeof window === 'undefined') {
return null;
}
return createPortal(overlayContent, document.body);
}
+20
View File
@@ -0,0 +1,20 @@
import { redirect } from 'next/navigation';
import { auth } from '@/app/api/auth/[...nextauth]/route';
import { isAdmin } from '@/lib/permissions';
import { ManageUsersContent } from './ManageUsersContent';
export default async function ManageUsersPage() {
const session = await auth();
if (!session?.user) {
redirect('/login?callbackUrl=/admin/users');
}
const admin = await isAdmin();
if (!admin) {
redirect('/');
}
return <ManageUsersContent />;
}
@@ -0,0 +1,155 @@
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { prismaAuth } from '@/lib/db';
import bcrypt from 'bcryptjs';
export const { handlers, signIn, signOut, auth } = NextAuth({
secret: process.env.NEXTAUTH_SECRET,
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
try {
if (!credentials?.email || !credentials?.password) {
console.log('[AUTH] Missing credentials');
return null;
}
console.log('[AUTH] Attempting to find user:', credentials.email);
const user = await prismaAuth.user.findUnique({
where: { email: credentials.email as string },
select: {
id: true,
email: true,
name: true,
passwordHash: true,
isAdmin: true,
hasWriteAccess: true,
emailVerified: true,
isActive: true,
},
});
if (!user) {
console.log('[AUTH] User not found:', credentials.email);
return null;
}
console.log('[AUTH] User found, checking password...');
const isPasswordValid = await bcrypt.compare(
credentials.password as string,
user.passwordHash
);
if (!isPasswordValid) {
console.log('[AUTH] Invalid password for user:', credentials.email);
return null;
}
// Check if email is verified
if (!user.emailVerified) {
console.log('[AUTH] Email not verified for user:', credentials.email);
return null; // Return null to indicate failed login
}
// Check if user is active (treat null/undefined as true)
if (user.isActive === false) {
console.log('[AUTH] User is inactive:', credentials.email);
return null; // Return null to indicate failed login
}
console.log('[AUTH] Login successful for:', credentials.email);
return {
id: user.id.toString(),
email: user.email,
name: user.name || undefined,
isAdmin: user.isAdmin,
hasWriteAccess: user.hasWriteAccess,
};
} catch (error: any) {
console.error('[AUTH] Error during authorization:', error);
return null;
}
},
}),
],
pages: {
signIn: '/login',
signOut: '/',
},
session: {
strategy: 'jwt',
maxAge: 24 * 60 * 60, // 24 hours in seconds
updateAge: 1 * 60 * 60, // Refresh session every 1 hour (more frequent validation)
},
jwt: {
maxAge: 24 * 60 * 60, // 24 hours in seconds
},
callbacks: {
async jwt({ token, user, trigger }) {
// Set expiration time when user first logs in
if (user) {
token.id = user.id;
token.email = user.email;
token.isAdmin = user.isAdmin;
token.hasWriteAccess = user.hasWriteAccess;
token.exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60); // 24 hours from now
}
// Refresh user data from database on token refresh to get latest hasWriteAccess and isActive
// This ensures permissions are up-to-date even if granted after login
if (token.email && !user) {
try {
const dbUser = await prismaAuth.user.findUnique({
where: { email: token.email as string },
select: {
id: true,
email: true,
isAdmin: true,
hasWriteAccess: true,
isActive: true,
},
});
if (dbUser) {
// Check if user is still active (treat null/undefined as true)
if (dbUser.isActive === false) {
// User was deactivated, invalidate token
return null as any;
}
token.id = dbUser.id.toString();
token.isAdmin = dbUser.isAdmin;
token.hasWriteAccess = dbUser.hasWriteAccess;
}
} catch (error) {
console.error('[AUTH] Error refreshing user data:', error);
// Continue with existing token data if refresh fails
}
}
return token;
},
async session({ session, token }) {
// If token is null or expired, return null session to force logout
if (!token || (token.exp && token.exp < Math.floor(Date.now() / 1000))) {
return null as any;
}
if (session.user) {
session.user.id = token.id as string;
session.user.email = token.email as string;
session.user.isAdmin = token.isAdmin as boolean;
session.user.hasWriteAccess = token.hasWriteAccess as boolean;
}
return session;
},
},
});
export const { GET, POST } = handlers;
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = body;
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password are required' },
{ status: 400 }
);
}
// Find user
const user = await prismaAuth.user.findUnique({
where: { email },
select: {
id: true,
email: true,
passwordHash: true,
emailVerified: true,
isActive: true,
},
});
if (!user) {
return NextResponse.json(
{ verified: false, exists: false },
{ status: 200 }
);
}
// Check if user is active (treat null/undefined as true)
if (user.isActive === false) {
return NextResponse.json(
{ verified: false, exists: true, passwordValid: false, active: false },
{ status: 200 }
);
}
// Check password
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
return NextResponse.json(
{ verified: false, exists: true, passwordValid: false },
{ status: 200 }
);
}
// Return verification status
return NextResponse.json(
{
verified: user.emailVerified,
exists: true,
passwordValid: true
},
{ status: 200 }
);
} catch (error: any) {
console.error('Error checking verification:', error);
return NextResponse.json(
{ error: 'Failed to check verification status' },
{ status: 500 }
);
}
}
@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import { generatePasswordResetToken, sendPasswordResetEmail } from '@/lib/email';
import { isValidEmail } from '@/lib/utils';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email } = body;
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
if (!isValidEmail(email)) {
return NextResponse.json(
{ error: 'Please enter a valid email address' },
{ status: 400 }
);
}
// Find user
const user = await prismaAuth.user.findUnique({
where: { email },
});
// Don't reveal if user exists or not for security
// Always return success message
if (!user) {
return NextResponse.json(
{ message: 'If an account with that email exists, a password reset email has been sent.' },
{ status: 200 }
);
}
// Check if user is active
if (user.isActive === false) {
return NextResponse.json(
{ message: 'If an account with that email exists, a password reset email has been sent.' },
{ status: 200 }
);
}
// Generate password reset token
const resetToken = generatePasswordResetToken();
const tokenExpiry = new Date();
tokenExpiry.setHours(tokenExpiry.getHours() + 1); // Token expires in 1 hour
// Update user with reset token
await prismaAuth.user.update({
where: { id: user.id },
data: {
passwordResetToken: resetToken,
passwordResetTokenExpiry: tokenExpiry,
},
});
// Send password reset email
try {
console.log('[FORGOT-PASSWORD] Attempting to send password reset email to:', user.email);
await sendPasswordResetEmail(user.email, user.name, resetToken);
console.log('[FORGOT-PASSWORD] Password reset email sent successfully to:', user.email);
} catch (emailError: any) {
console.error('[FORGOT-PASSWORD] Error sending password reset email:', emailError);
console.error('[FORGOT-PASSWORD] Error details:', {
message: emailError?.message,
name: emailError?.name,
response: emailError?.response,
statusCode: emailError?.statusCode,
});
// Clear the token if email fails
await prismaAuth.user.update({
where: { id: user.id },
data: {
passwordResetToken: null,
passwordResetTokenExpiry: null,
},
});
return NextResponse.json(
{
error: 'Failed to send password reset email',
details: emailError?.message || 'Unknown error'
},
{ status: 500 }
);
}
return NextResponse.json(
{ message: 'If an account with that email exists, a password reset email has been sent.' },
{ status: 200 }
);
} catch (error: any) {
console.error('Error processing password reset request:', error);
return NextResponse.json(
{ error: 'Failed to process password reset request' },
{ status: 500 }
);
}
}
@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { generateEmailConfirmationToken, sendEmailConfirmation } from '@/lib/email';
import { isValidEmail } from '@/lib/utils';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password, name } = body;
// Validate input
if (!email || !password || !name) {
return NextResponse.json(
{ error: 'Email, password, and name are required' },
{ status: 400 }
);
}
if (name.trim().length === 0) {
return NextResponse.json(
{ error: 'Name cannot be empty' },
{ status: 400 }
);
}
if (!isValidEmail(email)) {
return NextResponse.json(
{ error: 'Please enter a valid email address' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
// Check if user already exists
const existingUser = await prismaAuth.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'User with this email already exists' },
{ status: 409 }
);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Generate email confirmation token
const confirmationToken = generateEmailConfirmationToken();
const tokenExpiry = new Date();
tokenExpiry.setHours(tokenExpiry.getHours() + 24); // Token expires in 24 hours
// Create user (without write access by default, email not verified)
const user = await prismaAuth.user.create({
data: {
email,
passwordHash,
name: name.trim(),
hasWriteAccess: false, // New users don't have write access by default
emailVerified: false,
emailConfirmationToken: confirmationToken,
emailConfirmationTokenExpiry: tokenExpiry,
},
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
// Send confirmation email
try {
await sendEmailConfirmation(email, name.trim(), confirmationToken);
} catch (emailError) {
console.error('Error sending confirmation email:', emailError);
// Don't fail registration if email fails, but log it
// User can request a resend later
}
return NextResponse.json(
{
message: 'User created successfully. Please check your email to confirm your account.',
user,
requiresEmailConfirmation: true
},
{ status: 201 }
);
} catch (error: any) {
console.error('Error registering user:', error);
return NextResponse.json(
{ error: 'Failed to register user', details: error.message },
{ status: 500 }
);
}
}
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import { generateEmailConfirmationToken, sendEmailConfirmationResend } from '@/lib/email';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email } = body;
if (!email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
);
}
// Find user
const user = await prismaAuth.user.findUnique({
where: { email },
});
if (!user) {
// Don't reveal if user exists or not for security
return NextResponse.json(
{ message: 'If an account with that email exists, a confirmation email has been sent.' },
{ status: 200 }
);
}
// If already verified, don't send another email
if (user.emailVerified) {
return NextResponse.json(
{ message: 'Email is already verified.' },
{ status: 200 }
);
}
// Generate new token
const confirmationToken = generateEmailConfirmationToken();
const tokenExpiry = new Date();
tokenExpiry.setHours(tokenExpiry.getHours() + 24); // Token expires in 24 hours
// Update user with new token
await prismaAuth.user.update({
where: { id: user.id },
data: {
emailConfirmationToken: confirmationToken,
emailConfirmationTokenExpiry: tokenExpiry,
},
});
// Send confirmation email
try {
await sendEmailConfirmationResend(user.email, user.name, confirmationToken);
} catch (emailError) {
console.error('Error sending confirmation email:', emailError);
return NextResponse.json(
{ error: 'Failed to send confirmation email' },
{ status: 500 }
);
}
return NextResponse.json(
{ message: 'Confirmation email has been sent. Please check your inbox.' },
{ status: 200 }
);
} catch (error: any) {
console.error('Error resending confirmation email:', error);
return NextResponse.json(
{ error: 'Failed to resend confirmation email', details: error.message },
{ status: 500 }
);
}
}
@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import bcrypt from 'bcryptjs';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { token, password } = body;
if (!token || !password) {
return NextResponse.json(
{ error: 'Token and password are required' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
// Find user with this token
const user = await prismaAuth.user.findUnique({
where: { passwordResetToken: token },
});
if (!user) {
return NextResponse.json(
{ error: 'Invalid or expired reset token' },
{ status: 400 }
);
}
// Check if token has expired
if (user.passwordResetTokenExpiry && user.passwordResetTokenExpiry < new Date()) {
// Clear expired token
await prismaAuth.user.update({
where: { id: user.id },
data: {
passwordResetToken: null,
passwordResetTokenExpiry: null,
},
});
return NextResponse.json(
{ error: 'Reset token has expired. Please request a new password reset.' },
{ status: 400 }
);
}
// Hash new password
const passwordHash = await bcrypt.hash(password, 10);
// Update password and clear reset token
await prismaAuth.user.update({
where: { id: user.id },
data: {
passwordHash,
passwordResetToken: null,
passwordResetTokenExpiry: null,
},
});
return NextResponse.json(
{ message: 'Password has been reset successfully. You can now sign in with your new password.' },
{ status: 200 }
);
} catch (error: any) {
console.error('Error resetting password:', error);
return NextResponse.json(
{ error: 'Failed to reset password' },
{ status: 500 }
);
}
}
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const token = searchParams.get('token');
if (!token) {
return NextResponse.redirect(
new URL('/login?error=missing_token', request.url)
);
}
// Find user with this token
const user = await prismaAuth.user.findUnique({
where: { emailConfirmationToken: token },
});
if (!user) {
return NextResponse.redirect(
new URL('/login?error=invalid_token', request.url)
);
}
// Check if token has expired
if (user.emailConfirmationTokenExpiry && user.emailConfirmationTokenExpiry < new Date()) {
return NextResponse.redirect(
new URL('/login?error=token_expired', request.url)
);
}
// Check if already verified
if (user.emailVerified) {
return NextResponse.redirect(
new URL('/login?message=already_verified', request.url)
);
}
// Verify the email
await prismaAuth.user.update({
where: { id: user.id },
data: {
emailVerified: true,
emailConfirmationToken: null,
emailConfirmationTokenExpiry: null,
},
});
// Redirect to login with success message
return NextResponse.redirect(
new URL('/login?verified=true', request.url)
);
} catch (error: any) {
console.error('Error verifying email:', error);
return NextResponse.redirect(
new URL('/login?error=verification_failed', request.url)
);
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/app/api/auth/[...nextauth]/route';
// Debug endpoint to check session
export async function GET(request: NextRequest) {
try {
const session = await auth();
return NextResponse.json({
hasSession: !!session,
user: session?.user || null,
userId: session?.user?.id || null,
isAdmin: session?.user?.isAdmin || false,
hasWriteAccess: session?.user?.hasWriteAccess || false,
}, { status: 200 });
} catch (error: any) {
return NextResponse.json(
{ error: 'Failed to get session', details: error.message },
{ status: 500 }
);
}
}
@@ -0,0 +1,174 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, prismaAuth } from '@/lib/db';
import { auth } from '@/app/api/auth/[...nextauth]/route';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Check authentication
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Authentication required. Please sign in to identify faces.' },
{ status: 401 }
);
}
// Check write access
if (!session.user.hasWriteAccess) {
return NextResponse.json(
{ error: 'Write access required. You need write access to identify faces. Please contact an administrator.' },
{ status: 403 }
);
}
const { id } = await params;
const faceId = parseInt(id, 10);
if (isNaN(faceId)) {
return NextResponse.json(
{ error: 'Invalid face ID' },
{ status: 400 }
);
}
const body = await request.json();
const { personId, firstName, lastName, middleName, maidenName, dateOfBirth } = body;
let finalFirstName: string;
let finalLastName: string;
let finalMiddleName: string | null = null;
let finalMaidenName: string | null = null;
let finalDateOfBirth: Date | null = null;
// If personId is provided, fetch person data from database
if (personId) {
const person = await prisma.person.findUnique({
where: { id: parseInt(personId, 10) },
});
if (!person) {
return NextResponse.json(
{ error: 'Person not found' },
{ status: 404 }
);
}
finalFirstName = person.first_name;
finalLastName = person.last_name;
finalMiddleName = person.middle_name;
finalMaidenName = person.maiden_name;
finalDateOfBirth = person.date_of_birth;
} else {
// Validate required fields for new person
if (!firstName || !lastName) {
return NextResponse.json(
{ error: 'First name and last name are required' },
{ status: 400 }
);
}
finalFirstName = firstName;
finalLastName = lastName;
finalMiddleName = middleName || null;
finalMaidenName = maidenName || null;
// Parse date of birth if provided
const dob = dateOfBirth ? new Date(dateOfBirth) : null;
if (dateOfBirth && dob && isNaN(dob.getTime())) {
return NextResponse.json(
{ error: 'Invalid date of birth' },
{ status: 400 }
);
}
finalDateOfBirth = dob;
}
// Check if face exists (use read client for this - from punimtag database)
const face = await prisma.face.findUnique({
where: { id: faceId },
include: { Person: true },
});
if (!face) {
return NextResponse.json(
{ error: 'Face not found' },
{ status: 404 }
);
}
const userId = parseInt(session.user.id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user session' },
{ status: 401 }
);
}
// Check if there's already a pending identification for this face by this user
// Use auth client (connects to punimtag_auth database)
const existingPending = await prismaAuth.pendingIdentification.findFirst({
where: {
faceId,
userId,
status: 'pending',
},
});
if (existingPending) {
// Update existing pending identification
const updated = await prismaAuth.pendingIdentification.update({
where: { id: existingPending.id },
data: {
firstName: finalFirstName,
lastName: finalLastName,
middleName: finalMiddleName,
maidenName: finalMaidenName,
dateOfBirth: finalDateOfBirth,
},
});
return NextResponse.json({
message: 'Identification updated and pending approval',
pendingIdentification: updated,
});
}
// Create new pending identification
const pendingIdentification = await prismaAuth.pendingIdentification.create({
data: {
faceId,
userId,
firstName: finalFirstName,
lastName: finalLastName,
middleName: finalMiddleName,
maidenName: finalMaidenName,
dateOfBirth: finalDateOfBirth,
status: 'pending',
},
});
return NextResponse.json({
message: 'Identification submitted and pending approval',
pendingIdentification,
});
} catch (error: any) {
console.error('Error identifying face:', error);
// Handle unique constraint violation
if (error.code === 'P2002') {
return NextResponse.json(
{ error: 'A person with these details already exists' },
{ status: 409 }
);
}
return NextResponse.json(
{ error: 'Failed to identify face', details: error.message },
{ status: 500 }
);
}
}
+87
View File
@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
/**
* Health check endpoint that verifies database connectivity and permissions
* This runs automatically and can help detect permission issues early
*/
export async function GET() {
const checks: Record<string, { status: 'ok' | 'error'; message: string }> = {};
// Check database connection
try {
await prisma.$connect();
checks.database_connection = {
status: 'ok',
message: 'Database connection successful',
};
} catch (error: any) {
checks.database_connection = {
status: 'error',
message: `Database connection failed: ${error.message}`,
};
return NextResponse.json(
{
status: 'error',
checks,
message: 'Database health check failed',
},
{ status: 503 }
);
}
// Check permissions on key tables
const tables = [
{ name: 'photos', query: () => prisma.photo.findFirst() },
{ name: 'people', query: () => prisma.person.findFirst() },
{ name: 'faces', query: () => prisma.face.findFirst() },
{ name: 'tags', query: () => prisma.tag.findFirst() },
];
for (const table of tables) {
try {
await table.query();
checks[`table_${table.name}`] = {
status: 'ok',
message: `SELECT permission on ${table.name} table is OK`,
};
} catch (error: any) {
if (error.message?.includes('permission denied')) {
checks[`table_${table.name}`] = {
status: 'error',
message: `Permission denied on ${table.name} table. Run grant_readonly_permissions.sql as superuser.`,
};
} else {
checks[`table_${table.name}`] = {
status: 'error',
message: `Error accessing ${table.name}: ${error.message}`,
};
}
}
}
const hasErrors = Object.values(checks).some((check) => check.status === 'error');
return NextResponse.json(
{
status: hasErrors ? 'error' : 'ok',
checks,
timestamp: new Date().toISOString(),
...(hasErrors && {
fixInstructions: {
message: 'To fix permission errors, run as PostgreSQL superuser:',
command: 'psql -U postgres -d punimtag -f grant_readonly_permissions.sql',
},
}),
},
{ status: hasErrors ? 503 : 200 }
);
}
+81
View File
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const people = await prisma.person.findMany({
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
});
// Transform snake_case to camelCase for frontend
const transformedPeople = people.map((person) => ({
id: person.id,
firstName: person.first_name,
lastName: person.last_name,
middleName: person.middle_name,
maidenName: person.maiden_name,
dateOfBirth: person.date_of_birth,
createdDate: person.created_date,
}));
return NextResponse.json({ people: transformedPeople }, { status: 200 });
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted person data detected, attempting fallback query');
try {
// Try with minimal fields first
const people = await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
// Exclude potentially corrupted optional fields
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
// Transform snake_case to camelCase for frontend
const transformedPeople = people.map((person) => ({
id: person.id,
firstName: person.first_name,
lastName: person.last_name,
middleName: null,
maidenName: null,
dateOfBirth: null,
createdDate: null,
}));
return NextResponse.json({ people: transformedPeople }, { status: 200 });
} catch (fallbackError: any) {
console.error('Fallback person query also failed:', fallbackError);
return NextResponse.json(
{ error: 'Failed to fetch people', details: fallbackError.message },
{ status: 500 }
);
}
}
console.error('Error fetching people:', error);
return NextResponse.json(
{ error: 'Failed to fetch people', details: error.message },
{ status: 500 }
);
}
}
+394
View File
@@ -0,0 +1,394 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, prismaAuth } from '@/lib/db';
import { serializePhotos } from '@/lib/serialize';
import { auth } from '@/app/api/auth/[...nextauth]/route';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
// Parse query parameters
const people = searchParams.get('people')?.split(',').filter(Boolean).map(Number) || [];
const peopleMode = (searchParams.get('peopleMode') || 'any') as 'any' | 'all';
const tags = searchParams.get('tags')?.split(',').filter(Boolean).map(Number) || [];
const tagsMode = (searchParams.get('tagsMode') || 'any') as 'any' | 'all';
const dateFrom = searchParams.get('dateFrom');
const dateTo = searchParams.get('dateTo');
const mediaType = (searchParams.get('mediaType') || 'all') as 'all' | 'photos' | 'videos';
const favoritesOnly = searchParams.get('favoritesOnly') === 'true';
const page = parseInt(searchParams.get('page') || '1', 10);
const pageSize = parseInt(searchParams.get('pageSize') || '30', 10);
const skip = (page - 1) * pageSize;
// Get user session for favorites filter
const session = await auth();
let favoritePhotoIds: number[] = [];
if (favoritesOnly && session?.user?.id) {
const userId = parseInt(session.user.id, 10);
if (!isNaN(userId)) {
try {
const favorites = await prismaAuth.photoFavorite.findMany({
where: { userId },
select: { photoId: true },
});
favoritePhotoIds = favorites.map(f => f.photoId);
// If user has no favorites, return empty result
if (favoritePhotoIds.length === 0) {
return NextResponse.json({
photos: [],
total: 0,
page,
pageSize,
totalPages: 0,
});
}
} catch (error: any) {
// Handle case where table doesn't exist yet (P2021 = table does not exist)
if (error.code === 'P2021') {
console.warn('photo_favorites table does not exist yet. Run migration: migrations/add-photo-favorites-table.sql');
} else {
console.error('Error fetching favorites:', error);
}
// If favorites table doesn't exist or error, treat as no favorites
if (favoritesOnly) {
return NextResponse.json({
photos: [],
total: 0,
page,
pageSize,
totalPages: 0,
});
}
}
}
}
// Build where clause
const where: any = {
processed: true,
};
// Media type filter
if (mediaType !== 'all') {
if (mediaType === 'photos') {
where.media_type = 'image';
} else if (mediaType === 'videos') {
where.media_type = 'video';
}
}
// Date filter
if (dateFrom || dateTo) {
where.date_taken = {};
if (dateFrom) {
where.date_taken.gte = new Date(dateFrom);
}
if (dateTo) {
where.date_taken.lte = new Date(dateTo);
}
}
// People filter
if (people.length > 0) {
if (peopleMode === 'all') {
// Photo must have ALL selected people
where.AND = where.AND || [];
people.forEach((personId) => {
where.AND.push({
Face: {
some: {
person_id: personId,
},
},
});
});
} else {
// Photo has ANY of the selected people (default)
where.Face = {
some: {
person_id: { in: people },
},
};
}
}
// Tags filter
if (tags.length > 0) {
if (tagsMode === 'all') {
// Photo must have ALL selected tags
where.AND = where.AND || [];
tags.forEach((tagId) => {
where.AND.push({
PhotoTagLinkage: {
some: {
tag_id: tagId,
},
},
});
});
} else {
// Photo has ANY of the selected tags (default)
where.PhotoTagLinkage = {
some: {
tag_id: { in: tags },
},
};
}
}
// Favorites filter
if (favoritesOnly && favoritePhotoIds.length > 0) {
where.id = { in: favoritePhotoIds };
} else if (favoritesOnly && favoritePhotoIds.length === 0) {
// User has no favorites, return empty (already handled above, but keep for safety)
where.id = { in: [] };
}
// Execute query - load photos and relations separately
// Use raw query to read dates as strings and convert manually to avoid Prisma conversion issues
let photosBase: any[];
let total: number;
try {
// Build WHERE clause for raw SQL
const whereConditions: string[] = ['processed = true'];
const params: any[] = [];
let paramIndex = 1; // PostgreSQL uses $1, $2, etc.
if (mediaType !== 'all') {
if (mediaType === 'photos') {
whereConditions.push(`media_type = $${paramIndex}`);
params.push('image');
paramIndex++;
} else if (mediaType === 'videos') {
whereConditions.push(`media_type = $${paramIndex}`);
params.push('video');
paramIndex++;
}
}
if (dateFrom || dateTo) {
if (dateFrom) {
whereConditions.push(`date_taken >= $${paramIndex}`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
whereConditions.push(`date_taken <= $${paramIndex}`);
params.push(dateTo);
paramIndex++;
}
}
// Handle people filter - embed IDs directly since they're safe integers
if (people.length > 0) {
const peopleIds = people.join(',');
whereConditions.push(`id IN (
SELECT DISTINCT photo_id FROM faces WHERE person_id IN (${peopleIds})
)`);
}
// Handle tags filter - embed IDs directly since they're safe integers
if (tags.length > 0) {
const tagIds = tags.join(',');
whereConditions.push(`id IN (
SELECT DISTINCT photo_id FROM phototaglinkage WHERE tag_id IN (${tagIds})
)`);
}
// Handle favorites filter - embed IDs directly since they're safe integers
if (favoritesOnly && favoritePhotoIds.length > 0) {
const favIds = favoritePhotoIds.join(',');
whereConditions.push(`id IN (${favIds})`);
} else if (favoritesOnly && favoritePhotoIds.length === 0) {
whereConditions.push('1 = 0'); // No favorites, return empty
}
const whereClause = whereConditions.join(' AND ');
// Build query parameters (LIMIT and OFFSET are embedded directly as they're safe integers)
const queryParams = [...params];
const countParams = [...params];
// Use raw query to read dates as strings
// Note: LIMIT and OFFSET are embedded directly since they're integers and safe
const [photosRaw, totalResult] = await Promise.all([
prisma.$queryRawUnsafe<Array<{
id: number;
path: string;
filename: string;
date_added: string;
date_taken: string | null;
processed: boolean;
media_type: string | null;
}>>(
`SELECT
id,
path,
filename,
date_added,
date_taken,
processed,
media_type
FROM photos
WHERE ${whereClause}
ORDER BY date_taken DESC, id DESC
LIMIT ${pageSize} OFFSET ${skip}`,
...queryParams
),
prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
`SELECT COUNT(*) as count FROM photos WHERE ${whereClause}`,
...countParams
),
]);
// Convert date strings to Date objects
photosBase = photosRaw.map(photo => ({
id: photo.id,
path: photo.path,
filename: photo.filename,
date_added: new Date(photo.date_added),
date_taken: photo.date_taken ? new Date(photo.date_taken) : null,
processed: photo.processed,
media_type: photo.media_type,
}));
total = Number(totalResult[0].count);
} catch (error: any) {
console.error('Error loading photos:', error);
throw error;
}
// Load faces and tags separately
const photoIds = photosBase.map(p => p.id);
// Fetch faces
let faces: any[] = [];
try {
faces = await prisma.face.findMany({
where: { photo_id: { in: photoIds } },
select: {
id: true,
photo_id: true,
person_id: true,
location: true,
confidence: true,
quality_score: true,
is_primary_encoding: true,
detector_backend: true,
model_name: true,
face_confidence: true,
exif_orientation: true,
pose_mode: true,
yaw_angle: true,
pitch_angle: true,
roll_angle: true,
landmarks: true,
identified_by_user_id: true,
excluded: true,
Person: {
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
},
// Exclude encoding field (Bytes) to avoid P2023 conversion errors
},
});
} catch (faceError: any) {
if (faceError?.code === 'P2023' || faceError?.message?.includes('Conversion failed')) {
console.warn('Corrupted face data detected in search, skipping faces');
faces = [];
} else {
throw faceError;
}
}
// Fetch photo tag linkages with error handling
let photoTagLinkages: any[] = [];
try {
photoTagLinkages = await prisma.photoTagLinkage.findMany({
where: { photo_id: { in: photoIds } },
select: {
linkage_id: true,
photo_id: true,
tag_id: true,
linkage_type: true,
created_date: true,
Tag: {
select: {
id: true,
tag_name: true,
created_date: true,
},
},
},
});
} catch (linkageError: any) {
if (linkageError?.code === 'P2023' || linkageError?.message?.includes('Conversion failed')) {
console.warn('Corrupted photo tag linkage data detected, attempting fallback query');
try {
// Try with minimal fields
photoTagLinkages = await prisma.photoTagLinkage.findMany({
where: { photo_id: { in: photoIds } },
select: {
linkage_id: true,
photo_id: true,
tag_id: true,
// Exclude potentially corrupted fields
Tag: {
select: {
id: true,
tag_name: true,
// Exclude created_date if it's corrupted
},
},
},
});
} catch (fallbackError: any) {
console.error('Fallback photo tag linkage query also failed:', fallbackError);
// Return empty array as last resort to prevent API crash
photoTagLinkages = [];
}
} else {
throw linkageError;
}
}
// Combine the data manually
const photos = photosBase.map(photo => ({
...photo,
Face: faces.filter(face => face.photo_id === photo.id),
PhotoTagLinkage: photoTagLinkages.filter(link => link.photo_id === photo.id),
}));
return NextResponse.json({
photos: serializePhotos(photos),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
});
} catch (error) {
console.error('Search error:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorStack = error instanceof Error ? error.stack : undefined;
console.error('Error details:', { errorMessage, errorStack, error });
return NextResponse.json(
{
error: 'Failed to search photos',
details: errorMessage,
...(process.env.NODE_ENV === 'development' && { stack: errorStack })
},
{ status: 500 }
);
}
}
+324
View File
@@ -0,0 +1,324 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import { isAdmin } from '@/lib/permissions';
import bcrypt from 'bcryptjs';
// PATCH /api/users/[id] - Update user (admin only)
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Check if user is admin
const admin = await isAdmin();
if (!admin) {
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.' },
{ status: 403 }
);
}
const { id } = await params;
const userId = parseInt(id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
const body = await request.json();
const { hasWriteAccess, name, password, email, isAdmin: isAdminValue, isActive } = body;
// Prevent users from removing their own admin status
const session = await import('@/app/api/auth/[...nextauth]/route').then(
(m) => m.auth()
);
if (session?.user?.id && parseInt(session.user.id, 10) === userId) {
if (isAdminValue === false) {
return NextResponse.json(
{ error: 'You cannot remove your own admin status' },
{ status: 400 }
);
}
}
// Build update data
const updateData: {
hasWriteAccess?: boolean;
name?: string;
passwordHash?: string;
email?: string;
isAdmin?: boolean;
isActive?: boolean;
} = {};
if (typeof hasWriteAccess === 'boolean') {
updateData.hasWriteAccess = hasWriteAccess;
}
if (typeof isAdminValue === 'boolean') {
updateData.isAdmin = isAdminValue;
}
if (typeof isActive === 'boolean') {
updateData.isActive = isActive;
}
if (name !== undefined) {
if (!name || name.trim().length === 0) {
return NextResponse.json(
{ error: 'Name is required and cannot be empty' },
{ status: 400 }
);
}
updateData.name = name.trim();
}
if (email !== undefined) {
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: 'Invalid email format' },
{ status: 400 }
);
}
updateData.email = email;
}
if (password) {
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
updateData.passwordHash = await bcrypt.hash(password, 10);
}
if (Object.keys(updateData).length === 0) {
return NextResponse.json(
{ error: 'No valid fields to update' },
{ status: 400 }
);
}
// Update user
const user = await prismaAuth.user.update({
where: { id: userId },
data: updateData,
select: {
id: true,
email: true,
name: true,
isAdmin: true,
hasWriteAccess: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json(
{ message: 'User updated successfully', user },
{ status: 200 }
);
} catch (error: any) {
console.error('Error updating user:', error);
if (error.code === 'P2025') {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
if (error.code === 'P2002') {
// Unique constraint violation (likely email already exists)
return NextResponse.json(
{ error: 'Email already exists. Please use a different email address.' },
{ status: 409 }
);
}
return NextResponse.json(
{ error: 'Failed to update user', details: error.message },
{ status: 500 }
);
}
}
// DELETE /api/users/[id] - Delete user (admin only)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Check if user is admin
const admin = await isAdmin();
if (!admin) {
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.' },
{ status: 403 }
);
}
const { id } = await params;
const userId = parseInt(id, 10);
if (isNaN(userId)) {
return NextResponse.json(
{ error: 'Invalid user ID' },
{ status: 400 }
);
}
// Prevent deleting yourself
const session = await import('@/app/api/auth/[...nextauth]/route').then(
(m) => m.auth()
);
if (session?.user?.id && parseInt(session.user.id, 10) === userId) {
return NextResponse.json(
{ error: 'You cannot delete your own account' },
{ status: 400 }
);
}
// Check if user has any related records in other tables
let pendingIdentifications = 0;
let pendingPhotos = 0;
let inappropriatePhotoReports = 0;
let pendingLinkages = 0;
let photoFavorites = 0;
try {
[pendingIdentifications, pendingPhotos, inappropriatePhotoReports, pendingLinkages, photoFavorites] = await Promise.all([
prismaAuth.pendingIdentification.count({ where: { userId } }),
prismaAuth.pendingPhoto.count({ where: { userId } }),
prismaAuth.inappropriatePhotoReport.count({ where: { userId } }),
prismaAuth.pendingLinkage.count({ where: { userId } }),
prismaAuth.photoFavorite.count({ where: { userId } }),
]);
} catch (countError: any) {
console.error('Error counting related records:', countError);
// If counting fails, err on the side of caution and deactivate instead of delete
await prismaAuth.user.update({
where: { id: userId },
data: { isActive: false },
});
return NextResponse.json(
{
message: 'User deactivated successfully (error checking related records)',
deactivated: true
},
{ status: 200 }
);
}
console.log(`[DELETE User ${userId}] Related records:`, {
pendingIdentifications,
pendingPhotos,
inappropriatePhotoReports,
pendingLinkages,
photoFavorites,
});
// Ensure all counts are numbers and check explicitly
const counts = {
pendingIdentifications: Number(pendingIdentifications) || 0,
pendingPhotos: Number(pendingPhotos) || 0,
inappropriatePhotoReports: Number(inappropriatePhotoReports) || 0,
pendingLinkages: Number(pendingLinkages) || 0,
photoFavorites: Number(photoFavorites) || 0,
};
const hasRelatedRecords =
counts.pendingIdentifications > 0 ||
counts.pendingPhotos > 0 ||
counts.inappropriatePhotoReports > 0 ||
counts.pendingLinkages > 0 ||
counts.photoFavorites > 0;
console.log(`[DELETE User ${userId}] hasRelatedRecords:`, hasRelatedRecords, 'Counts:', counts);
if (hasRelatedRecords) {
console.log(`[DELETE User ${userId}] Deactivating user due to related records`);
// Set user as inactive instead of deleting
try {
await prismaAuth.user.update({
where: { id: userId },
data: { isActive: false },
});
console.log(`[DELETE User ${userId}] User deactivated successfully`);
} catch (updateError: any) {
console.error(`[DELETE User ${userId}] Error deactivating user:`, updateError);
throw updateError;
}
return NextResponse.json(
{
message: 'User deactivated successfully (user has related records in other tables)',
deactivated: true,
relatedRecords: {
pendingIdentifications: counts.pendingIdentifications,
pendingPhotos: counts.pendingPhotos,
inappropriatePhotoReports: counts.inappropriatePhotoReports,
pendingLinkages: counts.pendingLinkages,
photoFavorites: counts.photoFavorites,
}
},
{ status: 200 }
);
}
console.log(`[DELETE User ${userId}] No related records found, proceeding with deletion`);
// Double-check one more time before deleting (defensive programming)
const finalCheck = await Promise.all([
prismaAuth.pendingIdentification.count({ where: { userId } }),
prismaAuth.pendingPhoto.count({ where: { userId } }),
prismaAuth.inappropriatePhotoReport.count({ where: { userId } }),
prismaAuth.pendingLinkage.count({ where: { userId } }),
prismaAuth.photoFavorite.count({ where: { userId } }),
]);
const finalHasRelatedRecords = finalCheck.some(count => count > 0);
if (finalHasRelatedRecords) {
console.log(`[DELETE User ${userId}] Final check found related records, deactivating instead`);
await prismaAuth.user.update({
where: { id: userId },
data: { isActive: false },
});
return NextResponse.json(
{
message: 'User deactivated successfully (related records detected in final check)',
deactivated: true
},
{ status: 200 }
);
}
// No related records, safe to delete
console.log(`[DELETE User ${userId}] Confirmed no related records, deleting user`);
await prismaAuth.user.delete({
where: { id: userId },
});
console.log(`[DELETE User ${userId}] User deleted successfully`);
return NextResponse.json(
{ message: 'User deleted successfully' },
{ status: 200 }
);
} catch (error: any) {
console.error('Error deleting user:', error);
if (error.code === 'P2025') {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Failed to delete user', details: error.message },
{ status: 500 }
);
}
}
+173
View File
@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from 'next/server';
import { prismaAuth } from '@/lib/db';
import { isAdmin } from '@/lib/permissions';
import bcrypt from 'bcryptjs';
import { isValidEmail } from '@/lib/utils';
// GET /api/users - List all users (admin only)
export async function GET(request: NextRequest) {
try {
console.log('[API /users] Request received');
// Check if user is admin
console.log('[API /users] Checking admin status...');
const admin = await isAdmin();
console.log('[API /users] Admin check result:', admin);
if (!admin) {
console.log('[API /users] Unauthorized - user is not admin');
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.', message: 'You must be an administrator to access this resource.' },
{ status: 403 }
);
}
console.log('[API /users] User is admin, fetching users from database...');
// Get filter from query parameters
const { searchParams } = new URL(request.url);
const statusFilter = searchParams.get('status'); // 'all', 'active', 'inactive'
// Build where clause based on filter
let whereClause: any = {};
if (statusFilter === 'active') {
whereClause = { NOT: { isActive: false } }; // Active only (treat null/undefined as active)
} else if (statusFilter === 'inactive') {
whereClause = { isActive: false }; // Inactive only
}
// If 'all' or no filter, don't add where clause (get all users)
const users = await prismaAuth.user.findMany({
where: whereClause,
select: {
id: true,
email: true,
name: true,
isAdmin: true,
hasWriteAccess: true,
isActive: true,
createdAt: true,
updatedAt: true,
},
orderBy: {
createdAt: 'desc',
},
});
console.log('[API /users] Successfully fetched', users.length, 'users');
return NextResponse.json({ users }, { status: 200 });
} catch (error: any) {
console.error('[API /users] Error:', error);
console.error('[API /users] Error stack:', error.stack);
return NextResponse.json(
{
error: 'Failed to fetch users',
details: error.message,
message: error.message || 'An unexpected error occurred while fetching users.'
},
{ status: 500 }
);
}
}
// POST /api/users - Create new user (admin only)
export async function POST(request: NextRequest) {
try {
// Check if user is admin
const admin = await isAdmin();
if (!admin) {
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.' },
{ status: 403 }
);
}
const body = await request.json();
const {
email,
password,
name,
hasWriteAccess,
isAdmin: newUserIsAdmin,
} = body;
// Validate input
if (!email || !password || !name) {
return NextResponse.json(
{ error: 'Email, password, and name are required' },
{ status: 400 }
);
}
if (name.trim().length === 0) {
return NextResponse.json(
{ error: 'Name cannot be empty' },
{ status: 400 }
);
}
if (!isValidEmail(email)) {
return NextResponse.json(
{ error: 'Please enter a valid email address' },
{ status: 400 }
);
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Password must be at least 6 characters' },
{ status: 400 }
);
}
// Check if user already exists
const existingUser = await prismaAuth.user.findUnique({
where: { email },
});
if (existingUser) {
return NextResponse.json(
{ error: 'User with this email already exists' },
{ status: 409 }
);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create user (admin-created users are automatically verified)
const user = await prismaAuth.user.create({
data: {
email,
passwordHash,
name: name.trim(),
hasWriteAccess: hasWriteAccess ?? false,
isAdmin: newUserIsAdmin ?? false,
emailVerified: true, // Admin-created users are automatically verified
emailConfirmationToken: null, // No confirmation token needed
emailConfirmationTokenExpiry: null, // No expiry needed
},
select: {
id: true,
email: true,
name: true,
isAdmin: true,
hasWriteAccess: true,
createdAt: true,
updatedAt: true,
},
});
return NextResponse.json(
{ message: 'User created successfully', user },
{ status: 201 }
);
} catch (error: any) {
console.error('Error creating user:', error);
return NextResponse.json(
{ error: 'Failed to create user', details: error.message },
{ status: 500 }
);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+128
View File
@@ -0,0 +1,128 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
/* Blue as primary color (from logo) */
--primary: #1e40af;
--primary-foreground: oklch(0.985 0 0);
/* Blue for secondary/interactive elements - standard blue */
--secondary: #2563eb;
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
/* Blue accent */
--accent: #1e40af;
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: #1e40af;
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: #1e40af;
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: #2563eb;
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: #1e40af;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
/* Dark blue for cards in dark mode */
--card: oklch(0.25 0.08 250);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.25 0.08 250);
--popover-foreground: oklch(0.985 0 0);
/* Blue primary in dark mode (from logo) */
--primary: #3b82f6;
--primary-foreground: oklch(0.145 0 0);
/* Blue secondary in dark mode - standard blue */
--secondary: #3b82f6;
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: #3b82f6;
--accent-foreground: oklch(0.145 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: #3b82f6;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.25 0.08 250);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: #3b82f6;
--sidebar-primary-foreground: oklch(0.145 0 0);
--sidebar-accent: #3b82f6;
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: #3b82f6;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { SessionProviderWrapper } from "@/components/SessionProviderWrapper";
import "./globals.css";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const metadata: Metadata = {
title: "PunimTag Photo Viewer",
description: "Browse and search your family photos",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${inter.variable} font-sans antialiased`}>
<SessionProviderWrapper>
{children}
</SessionProviderWrapper>
</body>
</html>
);
}
+215
View File
@@ -0,0 +1,215 @@
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl') || '/';
const registered = searchParams.get('registered') === 'true';
const verified = searchParams.get('verified') === 'true';
const passwordReset = searchParams.get('passwordReset') === '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 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 {
router.push(callbackUrl);
router.refresh();
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
href="/register"
className="font-medium text-blue-600 hover:text-blue-500"
>
create a new account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{registered && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
Account created successfully! Please check your email to confirm your account before signing in.
</p>
</div>
)}
{verified && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
Email verified successfully! You can now sign in.
</p>
</div>
)}
{passwordReset && (
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
Password reset successfully! You can now sign in with your new password.
</p>
</div>
)}
{emailNotVerified && (
<div className="rounded-md bg-yellow-50 p-4">
<p className="text-sm text-yellow-800 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 underline hover:no-underline font-medium"
>
{isResending ? 'Sending...' : 'Resend confirmation email'}
</button>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="space-y-4 rounded-md shadow-sm">
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary">
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>
<label htmlFor="password" className="block text-sm font-medium text-secondary">
Password
</label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1"
placeholder="••••••••"
/>
</div>
</div>
<div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</div>
</form>
</div>
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
import { prisma } from '@/lib/db';
import { HomePageContent } from './HomePageContent';
import { Photo } from '@prisma/client';
import { serializePhotos, serializePeople, serializeTags } from '@/lib/serialize';
async function getAllPeople() {
try {
return await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted person data detected, attempting fallback query');
try {
// Try with minimal fields first
return await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
// Exclude potentially corrupted optional fields
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
} catch (fallbackError: any) {
console.error('Fallback person query also failed:', fallbackError);
// Return empty array as last resort to prevent page crash
return [];
}
}
// Re-throw if it's a different error
throw error;
}
}
async function getAllTags() {
try {
return await prisma.tag.findMany({
select: {
id: true,
tag_name: true,
created_date: true,
},
orderBy: { tag_name: 'asc' },
});
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted tag data detected, attempting fallback query');
try {
// Try with minimal fields
return await prisma.tag.findMany({
select: {
id: true,
tag_name: true,
// Exclude potentially corrupted date field
},
orderBy: { tag_name: 'asc' },
});
} catch (fallbackError: any) {
console.error('Fallback tag query also failed:', fallbackError);
// Return empty array as last resort to prevent page crash
return [];
}
}
// Re-throw if it's a different error
throw error;
}
}
export default async function HomePage() {
// Fetch photos from database
// Note: Make sure DATABASE_URL is set in .env file
let photos: any[] = []; // Using any to handle select-based query return type
let error: string | null = null;
try {
// Fetch first page of photos (30 photos) for initial load
// Infinite scroll will load more as user scrolls
// Try to load with date fields first, fallback if corrupted data exists
let photosBase;
try {
// Use raw query to read dates as strings and convert manually to avoid Prisma conversion issues
const photosRaw = await prisma.$queryRaw<Array<{
id: number;
path: string;
filename: string;
date_added: string;
date_taken: string | null;
processed: boolean;
media_type: string | null;
}>>`
SELECT
id,
path,
filename,
date_added,
date_taken,
processed,
media_type
FROM photos
WHERE processed = true
ORDER BY date_taken DESC, id DESC
LIMIT 30
`;
photosBase = photosRaw.map(photo => ({
id: photo.id,
path: photo.path,
filename: photo.filename,
date_added: new Date(photo.date_added),
date_taken: photo.date_taken ? new Date(photo.date_taken) : null,
processed: photo.processed,
media_type: photo.media_type,
}));
} catch (dateError: any) {
// If date fields are corrupted, load without them and use fallback values
// Check for P2023 error code or various date conversion error messages
const isDateError = dateError?.code === 'P2023' ||
dateError?.message?.includes('Conversion failed') ||
dateError?.message?.includes('Inconsistent column data') ||
dateError?.message?.includes('Could not convert value');
if (isDateError) {
console.warn('Corrupted date data detected, loading photos without date fields');
photosBase = await prisma.photo.findMany({
where: { processed: true },
select: {
id: true,
path: true,
filename: true,
processed: true,
media_type: true,
// Exclude date fields due to corruption
},
orderBy: { id: 'desc' },
take: 30,
});
// Add fallback date values
photosBase = photosBase.map(photo => ({
...photo,
date_added: new Date(),
date_taken: null,
}));
} else {
throw dateError;
}
}
// If base query works, load faces separately
const photoIds = photosBase.map(p => p.id);
const faces = await prisma.face.findMany({
where: { photo_id: { in: photoIds } },
select: {
id: true,
photo_id: true,
person_id: true,
location: true,
confidence: true,
quality_score: true,
is_primary_encoding: true,
detector_backend: true,
model_name: true,
face_confidence: true,
exif_orientation: true,
pose_mode: true,
yaw_angle: true,
pitch_angle: true,
roll_angle: true,
landmarks: true,
identified_by_user_id: true,
excluded: true,
Person: {
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
},
// Exclude encoding field (Bytes) to avoid P2023 conversion errors
},
});
// Combine the data manually
photos = photosBase.map(photo => ({
...photo,
Face: faces.filter(face => face.photo_id === photo.id),
})) as any;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load photos';
console.error('Error loading photos:', err);
}
// Fetch people and tags for search
const [people, tags] = await Promise.all([
getAllPeople(),
getAllTags(),
]);
return (
<main className="w-full px-4 py-8">
{error ? (
<div className="rounded-lg bg-red-50 p-4 text-red-800 dark:bg-red-900/20 dark:text-red-200">
<p className="font-semibold">Error loading photos</p>
<p className="text-sm">{error}</p>
<p className="mt-2 text-xs">
Make sure DATABASE_URL is configured in your .env file
</p>
</div>
) : (
<HomePageContent initialPhotos={serializePhotos(photos)} people={serializePeople(people)} tags={serializeTags(tags)} />
)}
</main>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { notFound } from 'next/navigation';
import { PhotoViewerClient } from '@/components/PhotoViewerClient';
import { prisma } from '@/lib/db';
import { serializePhoto, serializePhotos } from '@/lib/serialize';
async function getPhoto(id: number) {
try {
const photo = await prisma.photo.findUnique({
where: { id },
include: {
faces: {
include: {
person: true,
},
},
photoTags: {
include: {
tag: true,
},
},
},
});
return photo ? serializePhoto(photo) : null;
} catch (error) {
console.error('Error fetching photo:', error);
return null;
}
}
async function getPhotosByIds(ids: number[]) {
try {
const photos = await prisma.photo.findMany({
where: {
id: { in: ids },
processed: true,
},
include: {
faces: {
include: {
person: true,
},
},
photoTags: {
include: {
tag: true,
},
},
},
orderBy: { dateTaken: 'desc' },
});
return serializePhotos(photos);
} catch (error) {
console.error('Error fetching photos:', error);
return [];
}
}
export default async function PhotoPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ photos?: string; index?: string }>;
}) {
const { id } = await params;
const { photos: photosParam, index: indexParam } = await searchParams;
const photoId = parseInt(id, 10);
if (isNaN(photoId)) {
notFound();
}
// Get the current photo
const photo = await getPhoto(photoId);
if (!photo) {
notFound();
}
// If we have a photo list context, fetch all photos for client-side navigation
let allPhotos: typeof photo[] = [];
let currentIndex = 0;
if (photosParam && indexParam) {
const photoIds = photosParam.split(',').map(Number).filter(Boolean);
const parsedIndex = parseInt(indexParam, 10);
if (photoIds.length > 0 && !isNaN(parsedIndex)) {
allPhotos = await getPhotosByIds(photoIds);
// Maintain the original order from the photoIds array
const photoMap = new Map(allPhotos.map((p) => [p.id, p]));
allPhotos = photoIds.map((id) => photoMap.get(id)).filter(Boolean) as typeof photo[];
currentIndex = parsedIndex;
}
}
return (
<PhotoViewerClient
initialPhoto={photo}
allPhotos={allPhotos}
currentIndex={currentIndex}
/>
);
}
+185
View File
@@ -0,0 +1,185 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
import { isValidEmail } from '@/lib/utils';
export default function RegisterPage() {
const router = useRouter();
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 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 redirect to login
setName('');
setEmail('');
setPassword('');
setConfirmPassword('');
setError('');
router.push('/login?registered=true');
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Create your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500"
>
sign in to your existing account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="space-y-4 rounded-md shadow-sm">
<div>
<label htmlFor="name" className="block text-sm font-medium text-secondary">
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">
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">
Password
</label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1"
placeholder="••••••••"
/>
<p className="mt-1 text-xs text-gray-500">
Must be at least 6 characters
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary">
Confirm Password
</label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1"
placeholder="••••••••"
/>
</div>
</div>
<div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Creating account...' : 'Create account'}
</Button>
</div>
</form>
</div>
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
export default function ResetPasswordPage() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!token) {
setError('Invalid reset link. Please request a new password reset.');
}
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!token) {
setError('Invalid reset link. Please request a new password reset.');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
setIsLoading(true);
try {
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to reset password');
} else {
setSuccess(true);
// Redirect to login after 3 seconds
setTimeout(() => {
router.push('/login?passwordReset=true');
}, 3000);
}
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
if (success) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Password reset successful
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Your password has been reset successfully. Redirecting to login...
</p>
</div>
<div className="rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
You can now sign in with your new password.
</p>
</div>
<div className="text-center">
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500"
>
Go to login page
</Link>
</div>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Enter your new password below
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="space-y-4 rounded-md shadow-sm">
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary">
New Password
</label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1"
placeholder="••••••••"
/>
<p className="mt-1 text-xs text-gray-500">
Must be at least 6 characters
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary">
Confirm Password
</label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="mt-1"
placeholder="••••••••"
/>
</div>
</div>
<div>
<Button
type="submit"
className="w-full"
disabled={isLoading || !token}
>
{isLoading ? 'Resetting password...' : 'Reset password'}
</Button>
</div>
<div className="text-center">
<Link
href="/login"
className="font-medium text-blue-600 hover:text-blue-500"
>
Back to login
</Link>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,208 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Person, Tag, Photo } from '@prisma/client';
import { FilterPanel, SearchFilters } from '@/components/search/FilterPanel';
import { PhotoGrid } from '@/components/PhotoGrid';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
interface SearchContentProps {
people: Person[];
tags: Tag[];
}
export function SearchContent({ people, tags }: SearchContentProps) {
const router = useRouter();
const searchParams = useSearchParams();
// Initialize filters from URL params
const [filters, setFilters] = useState<SearchFilters>(() => {
const peopleParam = searchParams.get('people');
const tagsParam = searchParams.get('tags');
const dateFromParam = searchParams.get('dateFrom');
const dateToParam = searchParams.get('dateTo');
const mediaTypeParam = searchParams.get('mediaType');
const favoritesOnlyParam = searchParams.get('favoritesOnly');
return {
people: peopleParam ? peopleParam.split(',').map(Number).filter(Boolean) : [],
tags: tagsParam ? tagsParam.split(',').map(Number).filter(Boolean) : [],
dateFrom: dateFromParam ? new Date(dateFromParam) : undefined,
dateTo: dateToParam ? new Date(dateToParam) : undefined,
mediaType: (mediaTypeParam as 'all' | 'photos' | 'videos') || 'all',
favoritesOnly: favoritesOnlyParam === 'true',
};
});
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
// Update URL when filters change
useEffect(() => {
const params = new URLSearchParams();
if (filters.people.length > 0) {
params.set('people', filters.people.join(','));
}
if (filters.tags.length > 0) {
params.set('tags', filters.tags.join(','));
}
if (filters.dateFrom) {
params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]);
}
if (filters.dateTo) {
params.set('dateTo', filters.dateTo.toISOString().split('T')[0]);
}
if (filters.mediaType && filters.mediaType !== 'all') {
params.set('mediaType', filters.mediaType);
}
if (filters.favoritesOnly) {
params.set('favoritesOnly', 'true');
}
const newUrl = params.toString() ? `/search?${params.toString()}` : '/search';
router.replace(newUrl, { scroll: false });
}, [filters, router]);
// Reset to page 1 when filters change
useEffect(() => {
setPage(1);
}, [filters.people, filters.tags, filters.dateFrom, filters.dateTo, filters.mediaType, filters.favoritesOnly]);
// Fetch photos when filters or page change
useEffect(() => {
const fetchPhotos = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filters.people.length > 0) {
params.set('people', filters.people.join(','));
if (filters.peopleMode) {
params.set('peopleMode', filters.peopleMode);
}
}
if (filters.tags.length > 0) {
params.set('tags', filters.tags.join(','));
if (filters.tagsMode) {
params.set('tagsMode', filters.tagsMode);
}
}
if (filters.dateFrom) {
params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]);
}
if (filters.dateTo) {
params.set('dateTo', filters.dateTo.toISOString().split('T')[0]);
}
if (filters.mediaType && filters.mediaType !== 'all') {
params.set('mediaType', filters.mediaType);
}
if (filters.favoritesOnly) {
params.set('favoritesOnly', 'true');
}
params.set('page', page.toString());
params.set('pageSize', '30');
const response = await fetch(`/api/search?${params.toString()}`);
if (!response.ok) throw new Error('Failed to search photos');
const data = await response.json();
setPhotos(data.photos);
setTotal(data.total);
} catch (error) {
console.error('Error searching photos:', error);
} finally {
setLoading(false);
}
};
fetchPhotos();
}, [filters, page]);
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="grid grid-cols-1 gap-8 lg:grid-cols-4">
{/* Filter Panel */}
<div className="lg:col-span-1">
<FilterPanel
people={people}
tags={tags}
filters={filters}
onFiltersChange={setFilters}
/>
</div>
{/* Results */}
<div className="lg:col-span-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{total === 0 ? (
hasActiveFilters ? (
'No photos found matching your filters'
) : (
'Start by selecting filters to search photos'
)
) : (
`Found ${total} photo${total !== 1 ? 's' : ''}`
)}
</div>
</div>
{photos.length > 0 ? (
<>
<PhotoGrid photos={photos} />
{total > 30 && (
<div className="mt-8 flex justify-center gap-2">
<Button
variant="outline"
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<span className="flex items-center px-4 text-sm text-gray-600 dark:text-gray-400">
Page {page} of {Math.ceil(total / 30)}
</span>
<Button
variant="outline"
disabled={page >= Math.ceil(total / 30)}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
)}
</>
) : hasActiveFilters ? (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500">No photos found matching your filters</p>
</div>
) : (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500">Select filters to search photos</p>
</div>
)}
</>
)}
</div>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { Suspense } from 'react';
import { prisma } from '@/lib/db';
import { SearchContent } from './SearchContent';
import { PhotoGrid } from '@/components/PhotoGrid';
async function getAllPeople() {
try {
return await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted person data detected, attempting fallback query');
try {
// Try with minimal fields first
return await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
// Exclude potentially corrupted optional fields
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
} catch (fallbackError: any) {
console.error('Fallback person query also failed:', fallbackError);
// Return empty array as last resort to prevent page crash
return [];
}
}
// Re-throw if it's a different error
throw error;
}
}
async function getAllTags() {
try {
return await prisma.tag.findMany({
select: {
id: true,
tag_name: true,
created_date: true,
},
orderBy: { tag_name: 'asc' },
});
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted tag data detected, attempting fallback query');
try {
// Try with minimal fields
return await prisma.tag.findMany({
select: {
id: true,
tag_name: true,
// Exclude potentially corrupted date field
},
orderBy: { tag_name: 'asc' },
});
} catch (fallbackError: any) {
console.error('Fallback tag query also failed:', fallbackError);
// Return empty array as last resort to prevent page crash
return [];
}
}
// Re-throw if it's a different error
throw error;
}
}
export default async function SearchPage() {
const [people, tags] = await Promise.all([
getAllPeople(),
getAllTags(),
]);
return (
<main className="w-full px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
Search Photos
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Find photos by people, dates, and tags
</p>
</div>
<Suspense fallback={
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Loading search...</div>
</div>
}>
<SearchContent people={people} tags={tags} />
</Suspense>
</main>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { PhotoGrid } from '@/components/PhotoGrid';
import { Photo } from '@prisma/client';
/**
* Test page to verify direct URL access vs API proxy
*
* This page displays test images to verify:
* 1. Direct access works for HTTP/HTTPS URLs
* 2. API proxy works for file system paths
* 3. Automatic detection is working correctly
*/
export default function TestImagesPage() {
// Test photos with different path types
const testPhotos: Photo[] = [
// Test 1: Direct URL access (public test image)
{
id: 9991,
path: 'https://picsum.photos/800/600?random=1',
filename: 'test-direct-url-1.jpg',
dateAdded: new Date(),
dateTaken: null,
processed: true,
file_hash: 'test-hash-1',
media_type: 'image',
},
// Test 2: Another direct URL
{
id: 9992,
path: 'https://picsum.photos/800/600?random=2',
filename: 'test-direct-url-2.jpg',
dateAdded: new Date(),
dateTaken: null,
processed: true,
file_hash: 'test-hash-2',
media_type: 'image',
},
// Test 3: File system path (will use API proxy)
{
id: 9993,
path: '/nonexistent/path/test.jpg',
filename: 'test-file-system.jpg',
dateAdded: new Date(),
dateTaken: null,
processed: true,
file_hash: 'test-hash-3',
media_type: 'image',
},
];
return (
<main className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
Image Source Test Page
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Testing direct URL access vs API proxy
</p>
</div>
<div className="mb-6 rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<h2 className="mb-2 font-semibold text-blue-900 dark:text-blue-200">
Test Instructions:
</h2>
<ol className="list-inside list-decimal space-y-1 text-sm text-blue-800 dark:text-blue-300">
<li>Open browser DevTools (F12) Network tab</li>
<li>Filter by &quot;Img&quot; to see image requests</li>
<li>
<strong>Direct URL images</strong> should show requests to{' '}
<code className="rounded bg-blue-100 px-1 dark:bg-blue-800">
picsum.photos
</code>
</li>
<li>
<strong>File system images</strong> should show requests to{' '}
<code className="rounded bg-blue-100 px-1 dark:bg-blue-800">
/api/photos/...
</code>
</li>
</ol>
</div>
<div className="mb-4">
<h2 className="mb-2 text-xl font-semibold">Test Images</h2>
<div className="mb-2 text-sm text-gray-600 dark:text-gray-400">
<p>
<strong>Images 1-2:</strong> Direct URL access (should load from
picsum.photos)
</p>
<p>
<strong>Image 3:</strong> File system path (will use API proxy, may
show error if file doesn&apos;t exist)
</p>
</div>
</div>
<PhotoGrid photos={testPhotos} />
<div className="mt-8 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<h3 className="mb-2 font-semibold">Path Details:</h3>
<div className="space-y-2 text-sm">
{testPhotos.map((photo) => (
<div key={photo.id} className="font-mono text-xs">
<div>
<strong>ID {photo.id}:</strong> {photo.path}
</div>
<div className="ml-4 text-gray-600 dark:text-gray-400">
Type:{' '}
{photo.path.startsWith('http://') ||
photo.path.startsWith('https://')
? '✅ Direct URL'
: '📁 File System (API Proxy)'}
</div>
</div>
))}
</div>
</div>
</main>
);
}
@@ -0,0 +1,367 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { useSession } from 'next-auth/react';
import { Button } from '@/components/ui/button';
import { Upload, X, CheckCircle2, AlertCircle, Loader2, Play, Pause } from 'lucide-react';
interface UploadedFile {
file: File;
preview: string;
id: string;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
}
interface FilePreviewItemProps {
uploadedFile: UploadedFile;
onRemove: (id: string) => void;
}
function FilePreviewItem({ uploadedFile, onRemove }: FilePreviewItemProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const isVideo = uploadedFile.file.type.startsWith('video/');
const togglePlay = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const video = videoRef.current;
if (!video) return;
try {
if (video.paused) {
await video.play();
setIsPlaying(true);
} else {
video.pause();
setIsPlaying(false);
}
} catch (error) {
console.error('Error playing video:', error);
// If play() fails, try with muted
try {
video.muted = true;
await video.play();
setIsPlaying(true);
} catch (mutedError) {
console.error('Error playing video even when muted:', mutedError);
}
}
}, []);
return (
<div className="group relative aspect-square overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-900">
{isVideo ? (
<>
<video
ref={videoRef}
src={uploadedFile.preview}
className="h-full w-full object-cover"
playsInline
preload="metadata"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
onLoadedMetadata={() => {
// Video is ready to play
}}
/>
{/* Play/Pause Button Overlay */}
{uploadedFile.status === 'pending' && (
<button
onClick={togglePlay}
type="button"
className="absolute inset-0 z-20 flex items-center justify-center bg-black/20 hover:bg-black/30 transition-colors"
aria-label={isPlaying ? 'Pause video' : 'Play video'}
>
{isPlaying ? (
<Pause className="h-12 w-12 text-white opacity-80" />
) : (
<Play className="h-12 w-12 text-white opacity-80" />
)}
</button>
)}
</>
) : (
<img
src={uploadedFile.preview}
alt={uploadedFile.file.name}
className="h-full w-full object-cover"
/>
)}
{!isVideo && (
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
)}
{/* Status Overlay */}
{uploadedFile.status !== 'pending' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
{uploadedFile.status === 'uploading' && (
<Loader2 className="h-8 w-8 animate-spin text-white" />
)}
{uploadedFile.status === 'success' && (
<CheckCircle2 className="h-8 w-8 text-green-400" />
)}
{uploadedFile.status === 'error' && (
<AlertCircle className="h-8 w-8 text-red-400" />
)}
</div>
)}
{/* Remove Button */}
{uploadedFile.status === 'pending' && (
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onRemove(uploadedFile.id);
}}
className="absolute right-2 top-2 z-30 rounded-full bg-red-500 p-1.5 text-white opacity-0 transition-opacity group-hover:opacity-100 hover:bg-red-600"
aria-label="Remove file"
type="button"
>
<X className="h-4 w-4" />
</button>
)}
{/* File Name */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
<p className="truncate text-xs text-white">
{uploadedFile.file.name}
</p>
{uploadedFile.error && (
<p className="mt-1 text-xs text-red-300">
{uploadedFile.error}
</p>
)}
</div>
</div>
);
}
export function UploadContent() {
const { data: session } = useSession();
const [files, setFiles] = useState<UploadedFile[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const handleFileSelect = useCallback((selectedFiles: FileList | null) => {
if (!selectedFiles) return;
const newFiles: UploadedFile[] = Array.from(selectedFiles)
.filter((file) => file.type.startsWith('image/') || file.type.startsWith('video/'))
.map((file) => ({
file,
preview: URL.createObjectURL(file),
id: `${Date.now()}-${Math.random()}`,
status: 'pending' as const,
}));
setFiles((prev) => [...prev, ...newFiles]);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
handleFileSelect(e.dataTransfer.files);
},
[handleFileSelect]
);
const removeFile = useCallback((id: string) => {
setFiles((prev) => {
const file = prev.find((f) => f.id === id);
if (file) {
URL.revokeObjectURL(file.preview);
}
return prev.filter((f) => f.id !== id);
});
}, []);
const handleSubmit = useCallback(async () => {
if (files.length === 0 || !session?.user) return;
setIsSubmitting(true);
try {
const formData = new FormData();
files.forEach((uploadedFile) => {
formData.append('photos', uploadedFile.file);
});
// Update files to uploading status
setFiles((prev) =>
prev.map((f) => ({ ...f, status: 'uploading' as const }))
);
const response = await fetch('/api/photos/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to upload files');
}
const result = await response.json();
// Update files to success status
setFiles((prev) =>
prev.map((f) => ({ ...f, status: 'success' as const }))
);
// Clear files after 3 seconds
setTimeout(() => {
setFiles((currentFiles) => {
// Revoke object URLs to free memory
currentFiles.forEach((f) => URL.revokeObjectURL(f.preview));
return [];
});
}, 3000);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to upload files';
// Update files to error status
setFiles((prev) =>
prev.map((f) => ({
...f,
status: 'error' as const,
error: errorMessage,
}))
);
} finally {
setIsSubmitting(false);
}
}, [files, session]);
const pendingFiles = files.filter((f) => f.status === 'pending');
const hasPendingFiles = pendingFiles.length > 0;
const allSuccess = files.length > 0 && files.every((f) => f.status === 'success');
return (
<div className="space-y-6">
{/* Upload Area */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`relative rounded-lg border-2 border-dashed p-12 text-center transition-colors ${
isDragging
? 'border-primary bg-primary/5'
: 'border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600'
}`}
>
<input
type="file"
id="file-upload"
ref={fileInputRef}
multiple
accept="image/*,video/*"
className="hidden"
onChange={(e) => handleFileSelect(e.target.files)}
/>
<label
htmlFor="file-upload"
className="flex cursor-pointer flex-col items-center justify-center space-y-4"
>
<Upload
className={`h-12 w-12 ${
isDragging
? 'text-primary'
: 'text-gray-400 dark:text-gray-500'
}`}
/>
<div>
<span className="text-lg font-medium text-secondary dark:text-gray-50">
Drop photos and videos here or click to browse
</span>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Images: JPEG, PNG, GIF, WebP (max 50MB) | Videos: MP4, MOV, AVI, WebM (max 500MB)
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={(event) => {
event.preventDefault();
fileInputRef.current?.click();
}}
>
Select Files
</Button>
</label>
</div>
{/* File List */}
{files.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-secondary dark:text-gray-50">
Selected Files ({files.length})
</h2>
{!allSuccess && (
<Button
onClick={handleSubmit}
disabled={!hasPendingFiles || isSubmitting}
size="sm"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Submit for Review
</>
)}
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{files.map((uploadedFile) => (
<FilePreviewItem
key={uploadedFile.id}
uploadedFile={uploadedFile}
onRemove={removeFile}
/>
))}
</div>
{allSuccess && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4">
<div className="flex items-center space-x-2">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
<p className="text-sm font-medium text-green-800 dark:text-green-200">
Files submitted successfully! They are now pending admin review.
</p>
</div>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,72 @@
'use client';
import { useRouter } from 'next/navigation';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { UploadContent } from './UploadContent';
import Image from 'next/image';
import Link from 'next/link';
import UserMenu from '@/components/UserMenu';
export function UploadPageClient() {
const router = useRouter();
const handleClose = () => {
router.push('/');
};
return (
<div className="fixed inset-0 z-50 bg-background overflow-y-auto">
<div className="w-full px-4 py-8">
{/* Close button */}
<div className="mb-4 flex items-center justify-end">
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-9 w-9"
aria-label="Close upload"
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Header */}
<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>
{/* Upload content */}
<div className="mt-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
Upload Photos & Videos
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Upload your photos and videos for admin review. Once approved, they will be added to the collection.
</p>
</div>
<UploadContent />
</div>
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { auth } from '@/app/api/auth/[...nextauth]/route';
import { redirect } from 'next/navigation';
import { UploadPageClient } from './UploadPageClient';
export default async function UploadPage() {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
return <UploadPageClient />;
}