feat: add advanced search with filters for notes

- Create AdvancedSearch component with multiple filter options
- Add filters for tags, date range, content type (todos/images/files), and priority
- Integrate advanced search in desktop and mobile note pages
- Extract unique tags from notes for filter dropdown
- Support multiple simultaneous filters with clear visual feedback
- Reset pagination when filters change
This commit is contained in:
Claude committed 2025-11-27 13:48:02 +00:00
1 parent 0a22cc784f
commit a1addbacba
3 files changed
+457 -20

No files matched your search

+283
View File
@@ -0,0 +1,283 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Calendar } from "@/components/ui/calendar";
import { SlidersHorizontal, X, Calendar as CalendarIcon, Tag as TagIcon } from "lucide-react";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
export interface SearchFilters {
tags: string[]; // Tag names
dateFrom?: Date;
dateTo?: Date;
hasTodos?: boolean;
hasImages?: boolean;
hasFiles?: boolean;
priority?: boolean;
}
export interface TagOption {
id: number;
name: string;
}
interface AdvancedSearchProps {
filters: SearchFilters;
onFiltersChange: (filters: SearchFilters) => void;
availableTags: TagOption[];
}
export function AdvancedSearch({ filters, onFiltersChange, availableTags }: AdvancedSearchProps) {
const [open, setOpen] = useState(false);
const [datePickerOpen, setDatePickerOpen] = useState<'from' | 'to' | null>(null);
const [tagSelectorOpen, setTagSelectorOpen] = useState(false);
const hasActiveFilters = filters.tags.length > 0 ||
filters.dateFrom !== undefined ||
filters.dateTo !== undefined ||
filters.hasTodos !== undefined ||
filters.hasImages !== undefined ||
filters.hasFiles !== undefined ||
filters.priority !== undefined;
const clearFilters = () => {
onFiltersChange({
tags: [],
dateFrom: undefined,
dateTo: undefined,
hasTodos: undefined,
hasImages: undefined,
hasFiles: undefined,
priority: undefined
});
};
const toggleTag = (tagName: string) => {
const newTags = filters.tags.includes(tagName)
? filters.tags.filter(name => name !== tagName)
: [...filters.tags, tagName];
onFiltersChange({ ...filters, tags: newTags });
};
const selectedTags = availableTags.filter(tag => filters.tags.includes(tag.name));
return (
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<SlidersHorizontal className="h-4 w-4" />
Filtres avancés
{hasActiveFilters && (
<Badge variant="secondary" className="ml-1 px-1.5">
{filters.tags.length +
(filters.dateFrom ? 1 : 0) +
(filters.dateTo ? 1 : 0) +
(filters.hasTodos ? 1 : 0) +
(filters.hasImages ? 1 : 0) +
(filters.hasFiles ? 1 : 0) +
(filters.priority ? 1 : 0)}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="start">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">Filtres avancés</h4>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-auto p-0 text-xs text-muted-foreground hover:text-foreground"
>
Réinitialiser
</Button>
)}
</div>
{/* Tags Filter */}
<div className="space-y-2">
<Label className="text-xs font-medium">Tags</Label>
<Popover open={tagSelectorOpen} onOpenChange={setTagSelectorOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-full justify-start gap-2">
<TagIcon className="h-4 w-4" />
{selectedTags.length > 0 ? (
<span className="text-xs">
{selectedTags.length} tag{selectedTags.length > 1 ? 's' : ''} sélectionné{selectedTags.length > 1 ? 's' : ''}
</span>
) : (
<span className="text-xs text-muted-foreground">Sélectionner des tags</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-60 p-0" align="start">
<Command>
<CommandInput placeholder="Rechercher un tag..." />
<CommandList>
<CommandEmpty>Aucun tag trouvé</CommandEmpty>
<CommandGroup>
{availableTags.map((tag) => (
<CommandItem
key={tag.id}
onSelect={() => toggleTag(tag.name)}
className="cursor-pointer"
>
<Checkbox
checked={filters.tags.includes(tag.name)}
className="mr-2"
/>
<TagIcon className="h-3 w-3 mr-2" />
{tag.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{selectedTags.map((tag) => (
<Badge key={tag.id} variant="secondary" className="text-xs gap-1">
{tag.name}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => toggleTag(tag.name)}
/>
</Badge>
))}
</div>
)}
</div>
{/* Date Range Filter */}
<div className="space-y-2">
<Label className="text-xs font-medium">Période</Label>
<div className="grid grid-cols-2 gap-2">
<Popover open={datePickerOpen === 'from'} onOpenChange={(open) => setDatePickerOpen(open ? 'from' : null)}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="justify-start text-xs">
<CalendarIcon className="h-3 w-3 mr-2" />
{filters.dateFrom ? filters.dateFrom.toLocaleDateString('fr-FR') : 'Du'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={filters.dateFrom}
onSelect={(date) => {
onFiltersChange({ ...filters, dateFrom: date });
setDatePickerOpen(null);
}}
initialFocus
/>
</PopoverContent>
</Popover>
<Popover open={datePickerOpen === 'to'} onOpenChange={(open) => setDatePickerOpen(open ? 'to' : null)}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="justify-start text-xs">
<CalendarIcon className="h-3 w-3 mr-2" />
{filters.dateTo ? filters.dateTo.toLocaleDateString('fr-FR') : 'Au'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={filters.dateTo}
onSelect={(date) => {
onFiltersChange({ ...filters, dateTo: date });
setDatePickerOpen(null);
}}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
{(filters.dateFrom || filters.dateTo) && (
<Button
variant="ghost"
size="sm"
onClick={() => onFiltersChange({ ...filters, dateFrom: undefined, dateTo: undefined })}
className="h-auto p-0 text-xs text-muted-foreground hover:text-foreground"
>
Effacer les dates
</Button>
)}
</div>
{/* Content Type Filters */}
<div className="space-y-2">
<Label className="text-xs font-medium">Contenu</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="hasTodos"
checked={filters.hasTodos === true}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, hasTodos: checked ? true : undefined })
}
/>
<label htmlFor="hasTodos" className="text-xs cursor-pointer">
Avec des tâches
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="hasImages"
checked={filters.hasImages === true}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, hasImages: checked ? true : undefined })
}
/>
<label htmlFor="hasImages" className="text-xs cursor-pointer">
Avec des images
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="hasFiles"
checked={filters.hasFiles === true}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, hasFiles: checked ? true : undefined })
}
/>
<label htmlFor="hasFiles" className="text-xs cursor-pointer">
Avec des fichiers
</label>
</div>
</div>
</div>
{/* Priority Filter */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="priority"
checked={filters.priority === true}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, priority: checked ? true : undefined })
}
/>
<label htmlFor="priority" className="text-xs cursor-pointer font-medium">
Notes prioritaires uniquement
</label>
</div>
</div>
</div>
</PopoverContent>
</Popover>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-8 gap-1">
<X className="h-3 w-3" />
Effacer
</Button>
)}
</div>
);
}
+91 -15
View File
@@ -36,6 +36,7 @@ import { ModeToggle } from "@/components/mode-toggle";
import { downloadNoteAsMarkdown, downloadAllNotesAsMarkdown, downloadTodosAsMarkdown } from "@/utils/markdownExport";
import { TemplateSelector } from "@/components/TemplateSelector";
import type { NoteTemplate } from "@/utils/noteTemplates";
import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch";
// ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS =====
@@ -115,6 +116,15 @@ const Index = () => {
const [user, setUser] = useState<UserType | null>(null);
const [showAdmin, setShowAdmin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchFilters, setSearchFilters] = useState<SearchFilters>({
tags: [],
dateFrom: undefined,
dateTo: undefined,
hasTodos: undefined,
hasImages: undefined,
hasFiles: undefined,
priority: undefined
});
const [notes, setNotes] = useState<Note[]>([]);
const [openNote, setOpenNote] = useState<Note | null>(null);
const [users, setUsers] = useState<UserType[]>([]);
@@ -828,12 +838,71 @@ const Index = () => {
}
};
const filteredNotes = notes.filter(note =>
(showArchived ? note.archived : !note.archived) &&
(searchQuery === "" ||
(note.title && note.title.toLowerCase().includes(searchQuery.toLowerCase())) ||
(note.content && note.content.toLowerCase().includes(searchQuery.toLowerCase())))
);
// Extract unique tags from all notes
const availableTags: TagOption[] = Array.from(
new Map(
notes.flatMap(note =>
(note.tags || []).map(tag => [tag.name, { id: tag.id!, name: tag.name }])
)
).values()
).sort((a, b) => a.name.localeCompare(b.name));
const filteredNotes = notes.filter(note => {
// Archived filter
if (showArchived ? !note.archived : note.archived) return false;
// Text search
if (searchQuery !== "" &&
!(note.title && note.title.toLowerCase().includes(searchQuery.toLowerCase())) &&
!(note.content && note.content.toLowerCase().includes(searchQuery.toLowerCase()))) {
return false;
}
// Tags filter
if (searchFilters.tags.length > 0) {
const noteTags = (note.tags || []).map(t => t.name);
if (!searchFilters.tags.some(filterTag => noteTags.includes(filterTag))) {
return false;
}
}
// Date range filter
if (searchFilters.dateFrom || searchFilters.dateTo) {
const noteDate = new Date(note.updated_at || note.created_at || 0);
if (searchFilters.dateFrom) {
const fromDate = new Date(searchFilters.dateFrom);
fromDate.setHours(0, 0, 0, 0);
if (noteDate < fromDate) return false;
}
if (searchFilters.dateTo) {
const toDate = new Date(searchFilters.dateTo);
toDate.setHours(23, 59, 59, 999);
if (noteDate > toDate) return false;
}
}
// Has todos filter
if (searchFilters.hasTodos && (!note.todos || note.todos.length === 0)) {
return false;
}
// Has images filter
if (searchFilters.hasImages && (!note.images || note.images.length === 0)) {
return false;
}
// Has files filter
if (searchFilters.hasFiles && (!note.files || note.files.length === 0)) {
return false;
}
// Priority filter
if (searchFilters.priority && !note.priority) {
return false;
}
return true;
});
// Pagination for notes
const totalNotesPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE);
@@ -845,7 +914,7 @@ const Index = () => {
// Reset page when changing filters or search
useEffect(() => {
setNotesPage(0);
}, [showArchived, searchQuery]);
}, [showArchived, searchQuery, searchFilters]);
useEffect(() => {
setTodosActivePage(0);
@@ -1143,14 +1212,21 @@ const Index = () => {
</div>
</div>
<div className="relative w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher dans les notes..."
className="pl-10 h-12 text-base"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
<div className="flex items-center gap-2">
<div className="relative w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher dans les notes..."
className="pl-10 h-12 text-base"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<AdvancedSearch
filters={searchFilters}
onFiltersChange={setSearchFilters}
availableTags={availableTags}
/>
</div>
</div>
+83 -5
View File
@@ -10,12 +10,22 @@ import NotesService, { Note } from "@/services/NotesService";
import { showError, showSuccess } from "@/utils/toast";
import { TemplateSelector } from "@/components/TemplateSelector";
import type { NoteTemplate } from "@/utils/noteTemplates";
import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch";
const NOTES_PER_PAGE = 10;
export default function NotesPage() {
const [notes, setNotes] = useState<Note[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [searchFilters, setSearchFilters] = useState<SearchFilters>({
tags: [],
dateFrom: undefined,
dateTo: undefined,
hasTodos: undefined,
hasImages: undefined,
hasFiles: undefined,
priority: undefined
});
const [showArchived, setShowArchived] = useState(false);
const [page, setPage] = useState(0);
const [templateSelectorOpen, setTemplateSelectorOpen] = useState(false);
@@ -34,13 +44,71 @@ export default function NotesPage() {
loadNotes();
}, []);
// Extract unique tags from all notes
const availableTags: TagOption[] = Array.from(
new Map(
notes.flatMap(note =>
(note.tags || []).map(tag => [tag.name, { id: tag.id!, name: tag.name }])
)
).values()
).sort((a, b) => a.name.localeCompare(b.name));
const filteredNotes = notes
.filter(note => {
const matchArchived = showArchived ? note.archived : !note.archived;
const matchSearch = searchQuery === "" ||
note.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
note.content?.toLowerCase().includes(searchQuery.toLowerCase());
return matchArchived && matchSearch;
// Archived filter
if (showArchived ? !note.archived : note.archived) return false;
// Text search
if (searchQuery !== "" &&
!(note.title?.toLowerCase().includes(searchQuery.toLowerCase())) &&
!(note.content?.toLowerCase().includes(searchQuery.toLowerCase()))) {
return false;
}
// Tags filter
if (searchFilters.tags.length > 0) {
const noteTags = (note.tags || []).map(t => t.name);
if (!searchFilters.tags.some(filterTag => noteTags.includes(filterTag))) {
return false;
}
}
// Date range filter
if (searchFilters.dateFrom || searchFilters.dateTo) {
const noteDate = new Date(note.updated_at || note.created_at || 0);
if (searchFilters.dateFrom) {
const fromDate = new Date(searchFilters.dateFrom);
fromDate.setHours(0, 0, 0, 0);
if (noteDate < fromDate) return false;
}
if (searchFilters.dateTo) {
const toDate = new Date(searchFilters.dateTo);
toDate.setHours(23, 59, 59, 999);
if (noteDate > toDate) return false;
}
}
// Has todos filter
if (searchFilters.hasTodos && (!note.todos || note.todos.length === 0)) {
return false;
}
// Has images filter
if (searchFilters.hasImages && (!note.images || note.images.length === 0)) {
return false;
}
// Has files filter
if (searchFilters.hasFiles && (!note.files || note.files.length === 0)) {
return false;
}
// Priority filter
if (searchFilters.priority && !note.priority) {
return false;
}
return true;
})
.sort((a, b) => {
if (a.priority && !b.priority) return -1;
@@ -106,6 +174,16 @@ export default function NotesPage() {
/>
</div>
{/* Advanced Filters */}
<AdvancedSearch
filters={searchFilters}
onFiltersChange={(newFilters) => {
setSearchFilters(newFilters);
setPage(0);
}}
availableTags={availableTags}
/>
{/* Filter Tabs */}
<div className="flex gap-2">
<Button