mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-21 22:28:18 +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 { downloadNoteAsMarkdown, downloadAllNotesAsMarkdown, downloadTodosAsMarkdown } from "@/utils/markdownExport";
|
||||||
import { TemplateSelector } from "@/components/TemplateSelector";
|
import { TemplateSelector } from "@/components/TemplateSelector";
|
||||||
import type { NoteTemplate } from "@/utils/noteTemplates";
|
import type { NoteTemplate } from "@/utils/noteTemplates";
|
||||||
|
import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch";
|
||||||
|
|
||||||
// ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS =====
|
// ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS =====
|
||||||
|
|
||||||
@@ -115,6 +116,15 @@ const Index = () => {
|
|||||||
const [user, setUser] = useState<UserType | null>(null);
|
const [user, setUser] = useState<UserType | null>(null);
|
||||||
const [showAdmin, setShowAdmin] = useState(false);
|
const [showAdmin, setShowAdmin] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
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 [notes, setNotes] = useState<Note[]>([]);
|
||||||
const [openNote, setOpenNote] = useState<Note | null>(null);
|
const [openNote, setOpenNote] = useState<Note | null>(null);
|
||||||
const [users, setUsers] = useState<UserType[]>([]);
|
const [users, setUsers] = useState<UserType[]>([]);
|
||||||
@@ -828,12 +838,71 @@ const Index = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredNotes = notes.filter(note =>
|
// Extract unique tags from all notes
|
||||||
(showArchived ? note.archived : !note.archived) &&
|
const availableTags: TagOption[] = Array.from(
|
||||||
(searchQuery === "" ||
|
new Map(
|
||||||
(note.title && note.title.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
notes.flatMap(note =>
|
||||||
(note.content && note.content.toLowerCase().includes(searchQuery.toLowerCase())))
|
(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
|
// Pagination for notes
|
||||||
const totalNotesPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE);
|
const totalNotesPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE);
|
||||||
@@ -845,7 +914,7 @@ const Index = () => {
|
|||||||
// Reset page when changing filters or search
|
// Reset page when changing filters or search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNotesPage(0);
|
setNotesPage(0);
|
||||||
}, [showArchived, searchQuery]);
|
}, [showArchived, searchQuery, searchFilters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTodosActivePage(0);
|
setTodosActivePage(0);
|
||||||
@@ -1143,14 +1212,21 @@ const Index = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative w-96">
|
<div className="flex items-center gap-2">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
<div className="relative w-96">
|
||||||
<Input
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||||
type="search"
|
<Input
|
||||||
placeholder="Rechercher dans les notes..."
|
type="search"
|
||||||
className="pl-10 h-12 text-base"
|
placeholder="Rechercher dans les notes..."
|
||||||
value={searchQuery}
|
className="pl-10 h-12 text-base"
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AdvancedSearch
|
||||||
|
filters={searchFilters}
|
||||||
|
onFiltersChange={setSearchFilters}
|
||||||
|
availableTags={availableTags}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,12 +10,22 @@ import NotesService, { Note } from "@/services/NotesService";
|
|||||||
import { showError, showSuccess } from "@/utils/toast";
|
import { showError, showSuccess } from "@/utils/toast";
|
||||||
import { TemplateSelector } from "@/components/TemplateSelector";
|
import { TemplateSelector } from "@/components/TemplateSelector";
|
||||||
import type { NoteTemplate } from "@/utils/noteTemplates";
|
import type { NoteTemplate } from "@/utils/noteTemplates";
|
||||||
|
import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch";
|
||||||
|
|
||||||
const NOTES_PER_PAGE = 10;
|
const NOTES_PER_PAGE = 10;
|
||||||
|
|
||||||
export default function NotesPage() {
|
export default function NotesPage() {
|
||||||
const [notes, setNotes] = useState<Note[]>([]);
|
const [notes, setNotes] = useState<Note[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
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 [showArchived, setShowArchived] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [templateSelectorOpen, setTemplateSelectorOpen] = useState(false);
|
const [templateSelectorOpen, setTemplateSelectorOpen] = useState(false);
|
||||||
@@ -34,13 +44,71 @@ export default function NotesPage() {
|
|||||||
loadNotes();
|
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
|
const filteredNotes = notes
|
||||||
.filter(note => {
|
.filter(note => {
|
||||||
const matchArchived = showArchived ? note.archived : !note.archived;
|
// Archived filter
|
||||||
const matchSearch = searchQuery === "" ||
|
if (showArchived ? !note.archived : note.archived) return false;
|
||||||
note.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
note.content?.toLowerCase().includes(searchQuery.toLowerCase());
|
// Text search
|
||||||
return matchArchived && matchSearch;
|
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) => {
|
.sort((a, b) => {
|
||||||
if (a.priority && !b.priority) return -1;
|
if (a.priority && !b.priority) return -1;
|
||||||
@@ -106,6 +174,16 @@ export default function NotesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Filters */}
|
||||||
|
<AdvancedSearch
|
||||||
|
filters={searchFilters}
|
||||||
|
onFiltersChange={(newFilters) => {
|
||||||
|
setSearchFilters(newFilters);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
availableTags={availableTags}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Filter Tabs */}
|
{/* Filter Tabs */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in new issue
Block a user