mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-11 17:29:37 +02:00
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:
3 files changed
+457
-20
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user