feat: Add new scripts and update project structure for database management and user authentication

This commit introduces several new scripts for managing database operations, including user creation, permission grants, and data migrations. It also adds new documentation files to guide users through the setup and configuration processes. Additionally, the project structure is updated to enhance organization and maintainability, ensuring a smoother development experience for contributors. These changes support the ongoing transition to a web-based architecture and improve overall project functionality.
This commit is contained in:
Tanya
2026-01-06 13:53:24 -05:00
parent 713584dc04
commit de2144be2a
175 changed files with 35854 additions and 0 deletions
@@ -0,0 +1,208 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Person, Tag, Photo } from '@prisma/client';
import { FilterPanel, SearchFilters } from '@/components/search/FilterPanel';
import { PhotoGrid } from '@/components/PhotoGrid';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
interface SearchContentProps {
people: Person[];
tags: Tag[];
}
export function SearchContent({ people, tags }: SearchContentProps) {
const router = useRouter();
const searchParams = useSearchParams();
// Initialize filters from URL params
const [filters, setFilters] = useState<SearchFilters>(() => {
const peopleParam = searchParams.get('people');
const tagsParam = searchParams.get('tags');
const dateFromParam = searchParams.get('dateFrom');
const dateToParam = searchParams.get('dateTo');
const mediaTypeParam = searchParams.get('mediaType');
const favoritesOnlyParam = searchParams.get('favoritesOnly');
return {
people: peopleParam ? peopleParam.split(',').map(Number).filter(Boolean) : [],
tags: tagsParam ? tagsParam.split(',').map(Number).filter(Boolean) : [],
dateFrom: dateFromParam ? new Date(dateFromParam) : undefined,
dateTo: dateToParam ? new Date(dateToParam) : undefined,
mediaType: (mediaTypeParam as 'all' | 'photos' | 'videos') || 'all',
favoritesOnly: favoritesOnlyParam === 'true',
};
});
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
// Update URL when filters change
useEffect(() => {
const params = new URLSearchParams();
if (filters.people.length > 0) {
params.set('people', filters.people.join(','));
}
if (filters.tags.length > 0) {
params.set('tags', filters.tags.join(','));
}
if (filters.dateFrom) {
params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]);
}
if (filters.dateTo) {
params.set('dateTo', filters.dateTo.toISOString().split('T')[0]);
}
if (filters.mediaType && filters.mediaType !== 'all') {
params.set('mediaType', filters.mediaType);
}
if (filters.favoritesOnly) {
params.set('favoritesOnly', 'true');
}
const newUrl = params.toString() ? `/search?${params.toString()}` : '/search';
router.replace(newUrl, { scroll: false });
}, [filters, router]);
// Reset to page 1 when filters change
useEffect(() => {
setPage(1);
}, [filters.people, filters.tags, filters.dateFrom, filters.dateTo, filters.mediaType, filters.favoritesOnly]);
// Fetch photos when filters or page change
useEffect(() => {
const fetchPhotos = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filters.people.length > 0) {
params.set('people', filters.people.join(','));
if (filters.peopleMode) {
params.set('peopleMode', filters.peopleMode);
}
}
if (filters.tags.length > 0) {
params.set('tags', filters.tags.join(','));
if (filters.tagsMode) {
params.set('tagsMode', filters.tagsMode);
}
}
if (filters.dateFrom) {
params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]);
}
if (filters.dateTo) {
params.set('dateTo', filters.dateTo.toISOString().split('T')[0]);
}
if (filters.mediaType && filters.mediaType !== 'all') {
params.set('mediaType', filters.mediaType);
}
if (filters.favoritesOnly) {
params.set('favoritesOnly', 'true');
}
params.set('page', page.toString());
params.set('pageSize', '30');
const response = await fetch(`/api/search?${params.toString()}`);
if (!response.ok) throw new Error('Failed to search photos');
const data = await response.json();
setPhotos(data.photos);
setTotal(data.total);
} catch (error) {
console.error('Error searching photos:', error);
} finally {
setLoading(false);
}
};
fetchPhotos();
}, [filters, page]);
const hasActiveFilters =
filters.people.length > 0 ||
filters.tags.length > 0 ||
filters.dateFrom ||
filters.dateTo ||
(filters.mediaType && filters.mediaType !== 'all') ||
filters.favoritesOnly === true;
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-4">
{/* Filter Panel */}
<div className="lg:col-span-1">
<FilterPanel
people={people}
tags={tags}
filters={filters}
onFiltersChange={setFilters}
/>
</div>
{/* Results */}
<div className="lg:col-span-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{total === 0 ? (
hasActiveFilters ? (
'No photos found matching your filters'
) : (
'Start by selecting filters to search photos'
)
) : (
`Found ${total} photo${total !== 1 ? 's' : ''}`
)}
</div>
</div>
{photos.length > 0 ? (
<>
<PhotoGrid photos={photos} />
{total > 30 && (
<div className="mt-8 flex justify-center gap-2">
<Button
variant="outline"
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<span className="flex items-center px-4 text-sm text-gray-600 dark:text-gray-400">
Page {page} of {Math.ceil(total / 30)}
</span>
<Button
variant="outline"
disabled={page >= Math.ceil(total / 30)}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
)}
</>
) : hasActiveFilters ? (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500">No photos found matching your filters</p>
</div>
) : (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500">Select filters to search photos</p>
</div>
)}
</>
)}
</div>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { Suspense } from 'react';
import { prisma } from '@/lib/db';
import { SearchContent } from './SearchContent';
import { PhotoGrid } from '@/components/PhotoGrid';
async function getAllPeople() {
try {
return await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted person data detected, attempting fallback query');
try {
// Try with minimal fields first
return await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
// Exclude potentially corrupted optional fields
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
} catch (fallbackError: any) {
console.error('Fallback person query also failed:', fallbackError);
// Return empty array as last resort to prevent page crash
return [];
}
}
// Re-throw if it's a different error
throw error;
}
}
async function getAllTags() {
try {
return await prisma.tag.findMany({
select: {
id: true,
tag_name: true,
created_date: true,
},
orderBy: { tag_name: 'asc' },
});
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted tag data detected, attempting fallback query');
try {
// Try with minimal fields
return await prisma.tag.findMany({
select: {
id: true,
tag_name: true,
// Exclude potentially corrupted date field
},
orderBy: { tag_name: 'asc' },
});
} catch (fallbackError: any) {
console.error('Fallback tag query also failed:', fallbackError);
// Return empty array as last resort to prevent page crash
return [];
}
}
// Re-throw if it's a different error
throw error;
}
}
export default async function SearchPage() {
const [people, tags] = await Promise.all([
getAllPeople(),
getAllTags(),
]);
return (
<main className="w-full px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
Search Photos
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Find photos by people, dates, and tags
</p>
</div>
<Suspense fallback={
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Loading search...</div>
</div>
}>
<SearchContent people={people} tags={tags} />
</Suspense>
</main>
);
}