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:
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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 "Img" 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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user