'use client' import { useState, useEffect, useMemo, useRef, useCallback } from 'react' import { motion, AnimatePresence } from 'motion/react' import { Search, ChevronLeft, ChevronRight, X, CornerDownRight, Folder, HelpCircle, Command, FileText, Sparkles, Loader2, } from 'lucide-react' import { useRouter } from 'next/navigation' import { useNotebooks } from '@/context/notebooks-context' import type { Note } from '@/lib/types' interface SearchMatch { id: string noteId: string noteTitle: string path: string type: 'document' | 'heading' | 'paragraph' | 'list' headingLevel?: number text: string matchedText: string lineIndex: number } interface SearchModalProps { isOpen: boolean onClose: () => void } /** Strip HTML tags and decode basic entities for plain-text matching */ function stripHtml(html: string): string { return html .replace(/<[^>]+>/g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/ /g, ' ') .replace(/\s{2,}/g, ' ') .trim() } /** Safe RegExp escape */ function escapeRegExp(s: string) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } export function SearchModal({ isOpen, onClose }: SearchModalProps) { const router = useRouter() const { notebooks } = useNotebooks() const [query, setQuery] = useState('') const [useRegex, setUseRegex] = useState(false) const [caseSensitive, setCaseSensitive] = useState(false) const [searchInTrash, setSearchInTrash] = useState(false) const [savedQueries, setSavedQueries] = useState([]) const [results, setResults] = useState([]) const [isLoading, setIsLoading] = useState(false) const [overview, setOverview] = useState(null) const [overviewLoading, setOverviewLoading] = useState(false) const [selectedIndex, setSelectedIndex] = useState(0) const inputRef = useRef(null) const debounceRef = useRef | null>(null) // Load saved queries from localStorage useEffect(() => { try { const stored = localStorage.getItem('momento-search-saved') if (stored) setSavedQueries(JSON.parse(stored)) } catch {} }, []) // Focus input when opened useEffect(() => { if (isOpen) { setTimeout(() => inputRef.current?.focus(), 50) setQuery('') setResults([]) setSelectedIndex(0) } }, [isOpen]) // Rebuild notebook path helper const getNotebookPath = useCallback( (notebookId: string | null | undefined): string => { if (!notebookId) return '' const segments: string[] = [] let current = notebooks.find(n => n.id === notebookId) while (current) { segments.unshift(current.name) const parentId = current.parentId current = parentId ? notebooks.find(n => n.id === parentId) : undefined } return segments.join(' / ') }, [notebooks] ) // Fetch notes from API with debounce useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current) if (!query.trim()) { setResults([]) setIsLoading(false) return } setIsLoading(true) debounceRef.current = setTimeout(async () => { try { const params = new URLSearchParams({ search: query.trim(), limit: '40', ...(searchInTrash ? {} : {}), }) const res = await fetch(`/api/notes?${params}`) if (!res.ok) throw new Error('search failed') const data = await res.json() setResults(data.data ?? []) } catch { setResults([]) } finally { setIsLoading(false) } }, 280) return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } }, [query, searchInTrash]) // Fetch AI overview after results load useEffect(() => { if (!query.trim() || results.length === 0) { setOverview(null) return } setOverviewLoading(true) const timer = setTimeout(async () => { try { const topResults = results.slice(0, 5).map(r => ({ title: r.title || 'Sans titre', snippet: r.content?.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400) || '', })) const res = await fetch('/api/ai/search-overview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: query.trim(), results: topResults }), }) const data = await res.json() setOverview(data.hasRelevantInfo ? data.answer : null) } catch { setOverview(null) } finally { setOverviewLoading(false) } }, 500) return () => clearTimeout(timer) }, [query, results]) // Reset selected index when results change useEffect(() => { setSelectedIndex(0) }, [results, query]) // Build SearchMatch list from results for the left panel const filteredMatches = useMemo((): SearchMatch[] => { if (!query.trim() || results.length === 0) return [] const searchRegex = (() => { try { // BUG FIX: caseSensitive → 'g' (respect casse), insensitive → 'gi' const flag = caseSensitive ? 'g' : 'gi' const pattern = useRegex ? query : escapeRegExp(query) return new RegExp(pattern, flag) } catch { return null } })() if (!searchRegex) return [] const matches: SearchMatch[] = [] results.forEach(note => { const notebookPath = getNotebookPath(note.notebookId) const fullPath = notebookPath ? `${notebookPath} / ${note.title ?? 'Sans titre'}` : (note.title ?? 'Sans titre') // 1. Title match if (note.title && searchRegex.test(note.title)) { matches.push({ id: `${note.id}-title`, noteId: note.id, noteTitle: note.title ?? 'Sans titre', path: fullPath, type: 'document', text: note.title ?? '', matchedText: note.title ?? '', lineIndex: -1, }) } // 2. Content match — strip HTML, split by lines if (note.content) { const plainContent = stripHtml(note.content) const lines = plainContent.split(/\n|(?<=\.)\s+(?=[A-Z])/) lines.forEach((line, index) => { const trimmed = line.trim() if (!trimmed || trimmed.length < 10) return // Reset lastIndex for global regex searchRegex.lastIndex = 0 if (!searchRegex.test(trimmed)) return let type: 'heading' | 'paragraph' | 'list' = 'paragraph' let headingLevel: number | undefined let displayVal = trimmed.slice(0, 120) if (/^#{1,6}\s/.test(trimmed)) { type = 'heading' const m = trimmed.match(/^(#{1,6})\s+(.+)$/) if (m) { headingLevel = m[1].length; displayVal = m[2] } } else if (/^[-*+]\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed)) { type = 'list' displayVal = trimmed.replace(/^[-*+\d.]+\s+/, '').slice(0, 120) } matches.push({ id: `${note.id}-line-${index}`, noteId: note.id, noteTitle: note.title ?? 'Sans titre', path: fullPath, type, headingLevel, text: trimmed, matchedText: displayVal, lineIndex: index, }) }) } }) return matches.slice(0, 200) // cap pour les perf }, [results, query, useRegex, caseSensitive, getNotebookPath]) // Active match for preview panel const activeMatch = filteredMatches[selectedIndex] // Count distinct notes in results const docMatchesCount = useMemo(() => { return new Set(filteredMatches.map(m => m.noteId)).size }, [filteredMatches]) // Preview panel: highlighted context around the matched line const highlightedPreview = useMemo(() => { if (!activeMatch) return null const currentNote = results.find(n => n.id === activeMatch.noteId) if (!currentNote) return null if (!query.trim()) return

{stripHtml(currentNote.content).slice(0, 500)}

try { // Fixed flag for preview highlights const flag = caseSensitive ? 'g' : 'gi' const searchPattern = useRegex ? query : escapeRegExp(query) const highlightRegex = new RegExp(`(${searchPattern})`, flag) const plainContent = stripHtml(currentNote.content) const lines = plainContent.split(/\n|(?<=\.)\s+(?=[A-Z])/).filter(l => l.trim()) const targetIndex = activeMatch.lineIndex >= 0 ? activeMatch.lineIndex : 0 const startLine = Math.max(0, targetIndex - 2) const endLine = Math.min(lines.length - 1, targetIndex + 6) return (
{startLine > 0 && (
)} {lines.slice(startLine, endLine + 1).map((line, idx) => { const absoluteIdx = startLine + idx const isMatchLine = absoluteIdx === targetIndex highlightRegex.lastIndex = 0 const hasMatch = highlightRegex.test(line) highlightRegex.lastIndex = 0 const segments = line.split(highlightRegex) return (
{absoluteIdx + 1} {hasMatch ? segments.map((seg, sIdx) => { highlightRegex.lastIndex = 0 const isMatch = highlightRegex.test(seg) return isMatch ? ( {seg} ) : ( seg ) }) : line}
) })} {endLine < lines.length - 1 && (
)}
) } catch { return

{stripHtml(currentNote.content).slice(0, 600)}

} }, [activeMatch, results, query, useRegex, caseSensitive]) // Row highlight renderer const renderHighlightedRow = (text: string) => { if (!query.trim()) return {text} try { // Fixed: caseSensitive → 'g', insensitive → 'gi' const flag = caseSensitive ? 'g' : 'gi' const pattern = useRegex ? query : escapeRegExp(query) const regex = new RegExp(`(${pattern})`, flag) const segments = text.split(regex) return ( {segments.map((seg, i) => { regex.lastIndex = 0 return regex.test(seg) ? ( {seg} ) : ( seg ) })} ) } catch { return {text} } } // Keyboard navigation useEffect(() => { if (!isOpen) return const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose() } else if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(p => Math.min(p + 1, filteredMatches.length - 1)) } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(p => Math.max(p - 1, 0)) } else if (e.key === 'Enter') { e.preventDefault() if (filteredMatches[selectedIndex]) { router.push(`/home?openNote=${filteredMatches[selectedIndex].noteId}`) onClose() } } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [isOpen, selectedIndex, filteredMatches, onClose, router]) // Save/remove query from localStorage const handleSaveQuery = () => { if (!query.trim()) return setSavedQueries(prev => { const next = prev.includes(query.trim()) ? prev : [...prev.slice(-9), query.trim()] try { localStorage.setItem('momento-search-saved', JSON.stringify(next)) } catch {} return next }) } const handleRemoveQuery = () => { setSavedQueries(prev => { const next = prev.filter(q => q !== query.trim()) try { localStorage.setItem('momento-search-saved', JSON.stringify(next)) } catch {} return next }) } if (!isOpen) return null return (
{/* Search bar */}
setQuery(e.target.value)} placeholder="Rechercher dans toutes vos notes…" className="w-full text-sm pl-10 pr-28 py-2.5 rounded-xl border border-border/70 dark:border-zinc-800 bg-white/85 dark:bg-[#1C1C1C] text-ink dark:text-dark-ink placeholder-concrete/50 outline-none focus:border-blueprint transition-colors" />
{/* Saved queries */} {savedQueries.length > 0 && (
Favoris :
{savedQueries.map(sq => ( ))}
)}
{/* Status bar */}
{filteredMatches.length > 0 ? `${selectedIndex + 1}/${filteredMatches.length}` : '—'}
{isLoading ? 'Recherche en cours…' : filteredMatches.length > 0 ? `${filteredMatches.length} occurrence${filteredMatches.length > 1 ? 's' : ''} dans ${docMatchesCount} note${docMatchesCount > 1 ? 's' : ''}` : query.trim() ? 'Aucun résultat' : 'Tapez pour rechercher'}
{query.trim() && ( )}
{/* Dual panel */}
{/* Left — results list */}
{/* AI Overview */} {(overviewLoading || overview) && (
{overviewLoading ? 'Analyse...' : 'Réponse IA'}
{overviewLoading ? (
Synthèse des résultats...
) : (

{overview}

)}
)} {filteredMatches.map((m, idx) => { const isSelected = idx === selectedIndex return (
setSelectedIndex(idx)} onDoubleClick={() => { router.push(`/home?openNote=${m.noteId}`) onClose() }} className={`p-2.5 rounded-xl cursor-pointer text-left select-none relative flex flex-col gap-1 border transition-all ${ isSelected ? 'bg-white dark:bg-zinc-800 shadow-sm border-blueprint/25' : 'border-transparent hover:bg-black/[0.02] dark:hover:bg-white/[0.02]' }`} > {isSelected && (
)}
{m.type === 'document' && } {m.type === 'heading' && ( H{m.headingLevel ?? ''} )} {m.type === 'list' && ( LIST )} {m.type === 'paragraph' && ( TXT )} {m.noteTitle}
{renderHighlightedRow(m.matchedText)}
{m.path}
) })} {!isLoading && filteredMatches.length === 0 && (

{query.trim() ? 'Aucune note ne correspond.' : 'Tapez pour obtenir des résultats instantanés.'}

)} {isLoading && (
)}
{/* Right — preview panel */}
{activeMatch ? (
{activeMatch.path}

{activeMatch.noteTitle}

APERÇU CONTEXTUEL

{highlightedPreview}
ID: {activeMatch.noteId.slice(0, 8)}…
) : (

Aperçu du document

Sélectionnez un résultat pour explorer son contenu.

)}
{/* Footer keyboard hints */}
↑↓ naviguer Entrée ouvrir Échap fermer
Momento Search
) }