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,92 @@
'use client';
import { useState } from 'react';
import { Person, Tag } from '@prisma/client';
import { FilterPanel, SearchFilters } from './FilterPanel';
import { Button } from '@/components/ui/button';
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface CollapsibleSearchProps {
people: Person[];
tags: Tag[];
filters: SearchFilters;
onFiltersChange: (filters: SearchFilters) => void;
}
export function CollapsibleSearch({ people, tags, filters, onFiltersChange }: CollapsibleSearchProps) {
const [isExpanded, setIsExpanded] = useState(true);
const hasActiveFilters =
filters.people.length > 0 ||
filters.tags.length > 0 ||
filters.dateFrom ||
filters.dateTo;
return (
<div
className={cn(
'flex flex-col border-r bg-card transition-all duration-300 sticky top-0 self-start',
isExpanded ? 'w-80' : 'w-16',
'h-[calc(100vh-8rem)]'
)}
>
{/* Collapse/Expand Button */}
<div className="flex items-center justify-between border-b p-4 flex-shrink-0">
{isExpanded ? (
<>
<div className="flex items-center gap-2">
<Search className="h-4 w-4" />
<span className="font-medium text-secondary">Search & Filter</span>
{hasActiveFilters && (
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
{[
filters.people.length,
filters.tags.length,
filters.dateFrom || filters.dateTo ? 1 : 0,
].reduce((a, b) => a + b, 0)}
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(false)}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</>
) : (
<div className="flex items-center justify-center w-full">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(true)}
className="h-8 w-8 p-0 relative"
title="Expand search"
>
<ChevronRight className="h-4 w-4" />
{hasActiveFilters && (
<span className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-primary border-2 border-card" />
)}
</Button>
</div>
)}
</div>
{/* Expanded Filter Panel */}
{isExpanded && (
<div className="flex-1 overflow-y-auto min-h-0">
<FilterPanel
people={people}
tags={tags}
filters={filters}
onFiltersChange={onFiltersChange}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,182 @@
'use client';
import { useState } from 'react';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { CalendarIcon, X } from 'lucide-react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
interface DateRangeFilterProps {
dateFrom?: Date;
dateTo?: Date;
onDateChange: (dateFrom?: Date, dateTo?: Date) => void;
}
const datePresets = [
{ label: 'Today', getDates: () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return { from: today, to: new Date() };
}},
{ label: 'This Week', getDates: () => {
const today = new Date();
const weekStart = new Date(today);
weekStart.setDate(today.getDate() - today.getDay());
weekStart.setHours(0, 0, 0, 0);
return { from: weekStart, to: new Date() };
}},
{ label: 'This Month', getDates: () => {
const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
return { from: monthStart, to: new Date() };
}},
{ label: 'This Year', getDates: () => {
const today = new Date();
const yearStart = new Date(today.getFullYear(), 0, 1);
return { from: yearStart, to: new Date() };
}},
];
export function DateRangeFilter({ dateFrom, dateTo, onDateChange }: DateRangeFilterProps) {
const [open, setOpen] = useState(false);
const applyPreset = (preset: typeof datePresets[0]) => {
const { from, to } = preset.getDates();
onDateChange(from, to);
setOpen(false);
};
const clearDates = () => {
onDateChange(undefined, undefined);
};
return (
<div className="space-y-2">
<label className="text-sm font-medium text-secondary">Date Range</label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-full justify-start text-left font-normal',
!dateFrom && !dateTo && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateFrom && dateTo ? (
<>
{format(dateFrom, 'MMM d, yyyy')} - {format(dateTo, 'MMM d, yyyy')}
</>
) : (
'Select date range...'
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-3 space-y-2">
<div className="space-y-1">
<p className="text-sm font-medium text-secondary">Quick Presets</p>
<div className="flex flex-wrap gap-2">
{datePresets.map((preset) => (
<Button
key={preset.label}
variant="outline"
size="sm"
onClick={() => applyPreset(preset)}
className="text-xs"
>
{preset.label}
</Button>
))}
</div>
</div>
<div className="border-t pt-2">
<p className="text-sm font-medium text-secondary mb-2">Custom Range</p>
<Calendar
mode="range"
captionLayout="dropdown"
fromYear={1900}
toYear={new Date().getFullYear() + 10}
selected={{
from: dateFrom,
to: dateTo,
}}
onSelect={(range: { from?: Date; to?: Date } | undefined) => {
if (!range) {
onDateChange(undefined, undefined);
return;
}
// If both from and to are set, check if they're different dates
if (range.from && range.to) {
// Check if dates are on the same day
const fromDate = new Date(range.from);
fromDate.setHours(0, 0, 0, 0);
const toDate = new Date(range.to);
toDate.setHours(0, 0, 0, 0);
const sameDay = fromDate.getTime() === toDate.getTime();
if (!sameDay) {
// Valid range with different dates - complete selection and close
onDateChange(range.from, range.to);
setOpen(false);
} else {
// Same day - treat as "from" only, keep popover open for "to" selection
onDateChange(range.from, undefined);
}
} else if (range.from) {
// Only "from" is selected - keep popover open for "to" selection
onDateChange(range.from, undefined);
} else if (range.to) {
// Only "to" is selected (shouldn't happen in range mode, but handle it)
onDateChange(undefined, range.to);
}
}}
numberOfMonths={2}
/>
</div>
{(dateFrom || dateTo) && (
<div className="border-t pt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
clearDates();
setOpen(false);
}}
className="w-full text-xs"
>
<X className="mr-2 h-3 w-3" />
Clear Dates
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
{(dateFrom || dateTo) && (
<Badge variant="secondary" className="flex items-center gap-1 w-fit">
{dateFrom && dateTo ? (
<>
{format(dateFrom, 'MMM d')} - {format(dateTo, 'MMM d, yyyy')}
</>
) : dateFrom ? (
`From ${format(dateFrom, 'MMM d, yyyy')}`
) : (
`Until ${format(dateTo!, 'MMM d, yyyy')}`
)}
<button
onClick={clearDates}
className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
>
<X className="h-3 w-3" />
</button>
</Badge>
)}
</div>
);
}
@@ -0,0 +1,34 @@
'use client';
import { Checkbox } from '@/components/ui/checkbox';
import { Heart } from 'lucide-react';
interface FavoritesFilterProps {
value: boolean;
onChange: (value: boolean) => void;
disabled?: boolean;
}
export function FavoritesFilter({ value, onChange, disabled }: FavoritesFilterProps) {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-secondary">Favorites</label>
<div className="flex items-center space-x-2">
<Checkbox
id="favorites-only"
checked={value}
onCheckedChange={(checked) => onChange(checked === true)}
disabled={disabled}
/>
<label
htmlFor="favorites-only"
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<Heart className="h-4 w-4" />
Show favorites only
</label>
</div>
</div>
);
}
@@ -0,0 +1,115 @@
'use client';
import { Person, Tag } from '@prisma/client';
import { useSession } from 'next-auth/react';
import { PeopleFilter } from './PeopleFilter';
import { DateRangeFilter } from './DateRangeFilter';
import { TagFilter } from './TagFilter';
import { MediaTypeFilter } from './MediaTypeFilter';
import { FavoritesFilter } from './FavoritesFilter';
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
export interface SearchFilters {
people: number[];
peopleMode?: 'any' | 'all';
tags: number[];
tagsMode?: 'any' | 'all';
dateFrom?: Date;
dateTo?: Date;
mediaType?: 'all' | 'photos' | 'videos';
favoritesOnly?: boolean;
}
interface FilterPanelProps {
people: Person[];
tags: Tag[];
filters: SearchFilters;
onFiltersChange: (filters: SearchFilters) => void;
}
export function FilterPanel({ people, tags, filters, onFiltersChange }: FilterPanelProps) {
const { data: session } = useSession();
const isLoggedIn = Boolean(session);
const updateFilters = (updates: Partial<SearchFilters>) => {
onFiltersChange({ ...filters, ...updates });
};
const clearAllFilters = () => {
onFiltersChange({
people: [],
peopleMode: 'any',
tags: [],
tagsMode: 'any',
dateFrom: undefined,
dateTo: undefined,
mediaType: 'all',
favoritesOnly: false,
});
};
const hasActiveFilters =
filters.people.length > 0 ||
filters.tags.length > 0 ||
filters.dateFrom ||
filters.dateTo ||
(filters.mediaType && filters.mediaType !== 'all') ||
filters.favoritesOnly === true;
return (
<div className="space-y-6 p-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-secondary">Filters</h2>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="h-8"
>
<X className="mr-2 h-4 w-4" />
Clear All
</Button>
)}
</div>
{isLoggedIn && (
<PeopleFilter
people={people}
selected={filters.people}
mode={filters.peopleMode || 'any'}
onSelectionChange={(selected) => updateFilters({ people: selected })}
onModeChange={(mode) => updateFilters({ peopleMode: mode })}
/>
)}
<MediaTypeFilter
value={filters.mediaType || 'all'}
onChange={(value) => updateFilters({ mediaType: value })}
/>
{isLoggedIn && (
<FavoritesFilter
value={filters.favoritesOnly || false}
onChange={(value) => updateFilters({ favoritesOnly: value })}
/>
)}
<DateRangeFilter
dateFrom={filters.dateFrom}
dateTo={filters.dateTo}
onDateChange={(dateFrom, dateTo) => updateFilters({ dateFrom, dateTo })}
/>
<TagFilter
tags={tags}
selected={filters.tags}
mode={filters.tagsMode || 'any'}
onSelectionChange={(selected) => updateFilters({ tags: selected })}
onModeChange={(mode) => updateFilters({ tagsMode: mode })}
/>
</div>
);
}
@@ -0,0 +1,30 @@
'use client';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
export type MediaType = 'all' | 'photos' | 'videos';
interface MediaTypeFilterProps {
value: MediaType;
onChange: (value: MediaType) => void;
}
export function MediaTypeFilter({ value, onChange }: MediaTypeFilterProps) {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-secondary">Media type</label>
<Select value={value} onValueChange={(val) => onChange(val as MediaType)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="photos">Photos</SelectItem>
<SelectItem value="videos">Videos</SelectItem>
</SelectContent>
</Select>
</div>
);
}
@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import { Person } from '@prisma/client';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Search, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface PeopleFilterProps {
people: Person[];
selected: number[];
mode: 'any' | 'all';
onSelectionChange: (selected: number[]) => void;
onModeChange: (mode: 'any' | 'all') => void;
}
export function PeopleFilter({ people, selected, mode, onSelectionChange, onModeChange }: PeopleFilterProps) {
const [searchQuery, setSearchQuery] = useState('');
const [open, setOpen] = useState(false);
const filteredPeople = people.filter((person) => {
const fullName = `${person.firstName} ${person.lastName}`.toLowerCase();
return fullName.includes(searchQuery.toLowerCase());
});
const togglePerson = (personId: number) => {
if (selected.includes(personId)) {
onSelectionChange(selected.filter((id) => id !== personId));
} else {
onSelectionChange([...selected, personId]);
}
};
const selectedPeople = people.filter((p) => selected.includes(p.id));
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-secondary">People</label>
{selected.length > 1 && (
<Select value={mode} onValueChange={(value) => onModeChange(value as 'any' | 'all')}>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
)}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
<Search className="mr-2 h-4 w-4" />
{selected.length === 0 ? 'Select people...' : `${selected.length} selected`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<div className="p-2">
<Input
placeholder="Search people..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="mb-2"
/>
<div className="max-h-[300px] overflow-y-auto">
{filteredPeople.length === 0 ? (
<p className="p-2 text-sm text-gray-500">No people found</p>
) : (
<div className="space-y-1">
{filteredPeople.map((person) => {
const isSelected = selected.includes(person.id);
return (
<div
key={person.id}
className="flex items-center space-x-2 rounded-md p-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
onClick={() => togglePerson(person.id)}
>
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => togglePerson(person.id)}
/>
</span>
<label className="flex-1 cursor-pointer text-sm">
{person.firstName} {person.lastName}
</label>
</div>
);
})}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
{selectedPeople.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedPeople.map((person) => (
<Badge
key={person.id}
variant="secondary"
className="flex items-center gap-1"
>
{person.firstName} {person.lastName}
<button
onClick={() => togglePerson(person.id)}
className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,38 @@
'use client';
import { useState, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Search } from 'lucide-react';
interface SearchBarProps {
onSearch: (query: string) => void;
placeholder?: string;
defaultValue?: string;
}
export function SearchBar({ onSearch, placeholder = 'Search photos...', defaultValue = '' }: SearchBarProps) {
const [query, setQuery] = useState(defaultValue);
// Debounce search
useEffect(() => {
const timer = setTimeout(() => {
onSearch(query);
}, 300);
return () => clearTimeout(timer);
}, [query, onSearch]);
return (
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-10"
/>
</div>
);
}
@@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import { Tag } from '@prisma/client';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Search, X } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface TagFilterProps {
tags: Tag[];
selected: number[];
mode: 'any' | 'all';
onSelectionChange: (selected: number[]) => void;
onModeChange: (mode: 'any' | 'all') => void;
}
export function TagFilter({ tags, selected, mode, onSelectionChange, onModeChange }: TagFilterProps) {
const [searchQuery, setSearchQuery] = useState('');
const [open, setOpen] = useState(false);
const filteredTags = tags.filter((tag) => {
const tagName = tag.tagName || tag.tag_name || '';
return tagName.toLowerCase().includes(searchQuery.toLowerCase());
});
const toggleTag = (tagId: number) => {
if (selected.includes(tagId)) {
onSelectionChange(selected.filter((id) => id !== tagId));
} else {
onSelectionChange([...selected, tagId]);
}
};
const selectedTags = tags.filter((t) => selected.includes(t.id));
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-secondary">Tags</label>
{selected.length > 1 && (
<Select value={mode} onValueChange={(value) => onModeChange(value as 'any' | 'all')}>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
)}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left font-normal"
>
<Search className="mr-2 h-4 w-4" />
{selected.length === 0 ? 'Select tags...' : `${selected.length} selected`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<div className="p-2">
<Input
placeholder="Search tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="mb-2"
/>
<div className="max-h-[300px] overflow-y-auto">
{filteredTags.length === 0 ? (
<p className="p-2 text-sm text-gray-500">No tags found</p>
) : (
<div className="space-y-1">
{filteredTags.map((tag) => {
const isSelected = selected.includes(tag.id);
return (
<div
key={tag.id}
className="flex items-center space-x-2 rounded-md p-2 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
onClick={() => toggleTag(tag.id)}
>
<span onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleTag(tag.id)}
/>
</span>
<label className="flex-1 cursor-pointer text-sm">
{tag.tagName || tag.tag_name || 'Unnamed Tag'}
</label>
</div>
);
})}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedTags.map((tag) => (
<Badge
key={tag.id}
variant="secondary"
className="flex items-center gap-1"
>
{tag.tagName || tag.tag_name || 'Unnamed Tag'}
<button
onClick={() => toggleTag(tag.id)}
className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}