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