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