feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
651
memento-note/components/search-modal.tsx
Normal file
651
memento-note/components/search-modal.tsx
Normal file
@@ -0,0 +1,651 @@
|
||||
'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,
|
||||
} 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<string[]>([])
|
||||
const [results, setResults] = useState<Note[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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])
|
||||
|
||||
// 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 <p className="text-xs text-concrete p-2">{stripHtml(currentNote.content).slice(0, 500)}</p>
|
||||
|
||||
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 (
|
||||
<div className="space-y-0.5 my-1">
|
||||
{startLine > 0 && (
|
||||
<div className="text-[10px] text-concrete/40 italic pl-10 py-0.5">…</div>
|
||||
)}
|
||||
{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 (
|
||||
<div
|
||||
key={absoluteIdx}
|
||||
className={`py-1 px-2 rounded-lg text-xs leading-relaxed flex items-start gap-3 transition-colors ${
|
||||
isMatchLine
|
||||
? 'bg-blueprint/5 border-l-2 border-blueprint pl-2 dark:bg-blueprint/10'
|
||||
: 'opacity-70'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-[9px] text-concrete/40 text-right w-6 select-none mt-0.5 shrink-0">
|
||||
{absoluteIdx + 1}
|
||||
</span>
|
||||
<span className="font-sans text-ink dark:text-dark-ink break-words min-w-0">
|
||||
{hasMatch
|
||||
? segments.map((seg, sIdx) => {
|
||||
highlightRegex.lastIndex = 0
|
||||
const isMatch = highlightRegex.test(seg)
|
||||
return isMatch ? (
|
||||
<mark
|
||||
key={sIdx}
|
||||
className="bg-blueprint/20 text-ink dark:text-white dark:bg-blueprint/30 rounded px-0.5 border-b border-blueprint font-semibold"
|
||||
>
|
||||
{seg}
|
||||
</mark>
|
||||
) : (
|
||||
seg
|
||||
)
|
||||
})
|
||||
: line}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{endLine < lines.length - 1 && (
|
||||
<div className="text-[10px] text-concrete/40 italic pl-10 py-0.5">…</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} catch {
|
||||
return <p className="text-xs text-concrete p-2">{stripHtml(currentNote.content).slice(0, 600)}</p>
|
||||
}
|
||||
}, [activeMatch, results, query, useRegex, caseSensitive])
|
||||
|
||||
// Row highlight renderer
|
||||
const renderHighlightedRow = (text: string) => {
|
||||
if (!query.trim()) return <span className="truncate">{text}</span>
|
||||
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 (
|
||||
<span className="truncate">
|
||||
{segments.map((seg, i) => {
|
||||
regex.lastIndex = 0
|
||||
return regex.test(seg) ? (
|
||||
<mark key={i} className="bg-blueprint/25 text-ink dark:text-white dark:bg-blueprint/40 px-0.5 rounded font-bold">
|
||||
{seg}
|
||||
</mark>
|
||||
) : (
|
||||
seg
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
} catch {
|
||||
return <span className="truncate">{text}</span>
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="fixed inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-sm flex items-center justify-center z-[200] p-4 sm:p-6 select-none">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98, y: 8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-full max-w-[860px] h-[580px] sm:h-[640px] rounded-2xl bg-white dark:bg-[#121212] border border-border dark:border-zinc-800 shadow-2xl flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Search bar */}
|
||||
<div className="p-4 border-b border-border/60 dark:border-zinc-800 bg-paper/50 dark:bg-[#161616] flex flex-col gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2.5 relative">
|
||||
<Search size={17} className="text-concrete absolute left-3 top-1/2 -translate-y-1/2 shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCaseSensitive(c => !c)}
|
||||
title="Respecter la casse"
|
||||
className={`px-1.5 py-1 text-[9.5px] font-bold rounded-md transition-colors select-none ${
|
||||
caseSensitive ? 'text-blueprint bg-blueprint/8' : 'text-concrete hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUseRegex(r => !r)}
|
||||
title="Mode regex"
|
||||
className={`px-1.5 py-1 text-[9.5px] font-bold rounded-md transition-colors select-none ${
|
||||
useRegex ? 'text-blueprint bg-blueprint/8' : 'text-concrete hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
<button onClick={onClose} className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-md text-concrete transition-all">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saved queries */}
|
||||
{savedQueries.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-concrete">
|
||||
<span className="uppercase text-[9px] font-bold">Favoris :</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{savedQueries.map(sq => (
|
||||
<button
|
||||
key={sq}
|
||||
onClick={() => setQuery(sq)}
|
||||
className={`px-2 py-0.5 rounded-md border text-[9.5px] font-medium transition-all hover:border-blueprint ${
|
||||
query === sq
|
||||
? 'bg-blueprint/10 border-blueprint text-blueprint'
|
||||
: 'bg-white dark:bg-zinc-800 border-border/40 text-concrete'
|
||||
}`}
|
||||
>
|
||||
{sq}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="px-4 py-2 bg-[#F8F7F4] dark:bg-[#141414] border-b border-border/40 dark:border-zinc-800 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-0.5 border border-border/40 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-lg p-0.5">
|
||||
<button
|
||||
disabled={filteredMatches.length === 0}
|
||||
onClick={() => setSelectedIndex(p => Math.max(0, p - 1))}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-concrete disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</button>
|
||||
<span className="text-[9.5px] font-bold font-mono px-1.5 text-concrete">
|
||||
{filteredMatches.length > 0 ? `${selectedIndex + 1}/${filteredMatches.length}` : '—'}
|
||||
</span>
|
||||
<button
|
||||
disabled={filteredMatches.length === 0}
|
||||
onClick={() => setSelectedIndex(p => Math.min(filteredMatches.length - 1, p + 1))}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-concrete disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-concrete">
|
||||
{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'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{query.trim() && (
|
||||
<button
|
||||
onClick={savedQueries.includes(query.trim()) ? handleRemoveQuery : handleSaveQuery}
|
||||
className="text-[10px] font-bold uppercase tracking-wider text-blueprint border-b border-dashed border-blueprint hover:border-solid"
|
||||
>
|
||||
{savedQueries.includes(query.trim()) ? 'Retirer favori' : 'Sauvegarder'}
|
||||
</button>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-[10.5px] font-medium text-concrete">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={searchInTrash}
|
||||
onChange={e => setSearchInTrash(e.target.checked)}
|
||||
className="rounded border-border/60 text-blueprint focus:ring-blueprint w-3 h-3"
|
||||
/>
|
||||
<span>Corbeille incluse</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dual panel */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left — results list */}
|
||||
<div className="w-[45%] h-full border-r border-border/40 dark:border-zinc-800 flex flex-col bg-[#FAF9F5]/30 dark:bg-[#121212]/30 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{filteredMatches.map((m, idx) => {
|
||||
const isSelected = idx === selectedIndex
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => 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 && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3.5 bg-blueprint rounded-r-full" />
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{m.type === 'document' && <FileText size={12} className="text-sky-500 shrink-0" />}
|
||||
{m.type === 'heading' && (
|
||||
<span className="text-[8px] font-extrabold uppercase bg-indigo-50 dark:bg-indigo-950/40 text-indigo-500 border border-indigo-200 dark:border-indigo-900 px-1 rounded-sm shrink-0 font-mono">
|
||||
H{m.headingLevel ?? ''}
|
||||
</span>
|
||||
)}
|
||||
{m.type === 'list' && (
|
||||
<span className="text-[8px] font-extrabold uppercase bg-emerald-50 dark:bg-emerald-950/40 text-emerald-600 border border-emerald-200 dark:border-emerald-900 px-1 rounded-sm shrink-0 font-mono">
|
||||
LIST
|
||||
</span>
|
||||
)}
|
||||
{m.type === 'paragraph' && (
|
||||
<span className="text-[8px] font-extrabold uppercase bg-zinc-100 dark:bg-zinc-800 text-concrete border border-border/20 px-1 rounded-sm shrink-0 font-mono">
|
||||
TXT
|
||||
</span>
|
||||
)}
|
||||
<span className={`font-semibold truncate text-xs ${isSelected ? 'text-ink dark:text-dark-ink' : 'text-muted-foreground'}`}>
|
||||
{m.noteTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-concrete truncate pl-5 font-sans leading-tight">
|
||||
{renderHighlightedRow(m.matchedText)}
|
||||
</div>
|
||||
<div className="text-[8.5px] font-mono tracking-widest uppercase text-concrete/45 truncate pl-5 mt-0.5">
|
||||
{m.path}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isLoading && filteredMatches.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-6 text-concrete pt-24 space-y-2">
|
||||
<Search size={20} className="opacity-30 animate-pulse" />
|
||||
<p className="text-[11px] font-medium italic opacity-70">
|
||||
{query.trim() ? 'Aucune note ne correspond.' : 'Tapez pour obtenir des résultats instantanés.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="h-full flex items-center justify-center text-concrete pt-24">
|
||||
<div className="w-4 h-4 border-2 border-blueprint/30 border-t-blueprint rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — preview panel */}
|
||||
<div className="flex-1 h-full bg-[#FCFCFA]/80 dark:bg-[#151515] flex flex-col overflow-hidden">
|
||||
{activeMatch ? (
|
||||
<div className="flex-1 flex flex-col p-5 overflow-hidden">
|
||||
<div className="space-y-3 overflow-hidden flex flex-col flex-1">
|
||||
<div className="flex items-center gap-1.5 p-2 bg-black/[0.02] dark:bg-white/[0.02] border border-border/40 rounded-xl">
|
||||
<Folder size={11} className="text-concrete shrink-0" />
|
||||
<span className="text-[9.5px] font-mono tracking-widest text-concrete font-medium uppercase truncate">
|
||||
{activeMatch.path}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border/40 dark:border-zinc-800 pb-2">
|
||||
<h4 className="text-[13px] font-serif font-bold text-ink dark:text-dark-ink">
|
||||
{activeMatch.noteTitle}
|
||||
</h4>
|
||||
<p className="text-[8px] uppercase tracking-wider text-concrete font-bold mt-0.5">
|
||||
APERÇU CONTEXTUEL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-white dark:bg-[#121212] border border-border/30 dark:border-zinc-800 rounded-xl p-3.5 shadow-inner min-h-0">
|
||||
{highlightedPreview}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border/40 dark:border-zinc-800 flex items-center justify-between shrink-0 mt-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push(`/home?openNote=${activeMatch.noteId}`)
|
||||
onClose()
|
||||
}}
|
||||
className="px-5 py-2.5 bg-ink text-white dark:bg-white dark:text-black hover:opacity-90 text-xs font-semibold rounded-xl flex items-center gap-2 transition-all shadow-sm"
|
||||
>
|
||||
<CornerDownRight size={13} />
|
||||
<span>Ouvrir dans l'éditeur</span>
|
||||
</button>
|
||||
<span className="text-[10px] text-concrete font-bold font-mono bg-paper dark:bg-white/5 border border-border/30 px-2 py-1 rounded">
|
||||
ID: {activeMatch.noteId.slice(0, 8)}…
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6 text-concrete space-y-3">
|
||||
<HelpCircle size={24} className="opacity-25" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11.5px] font-bold">Aperçu du document</p>
|
||||
<p className="text-[10px] italic opacity-60">
|
||||
Sélectionnez un résultat pour explorer son contenu.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer keyboard hints */}
|
||||
<div className="p-3 bg-[#FAF9F5] dark:bg-[#0E0E0E] border-t border-border/50 dark:border-zinc-800 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-5 text-[9.5px] font-bold text-concrete/70">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-white text-[9px]">↑↓</kbd>
|
||||
naviguer
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-white text-[9px]">Entrée</kbd>
|
||||
ouvrir
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-white text-[9px]">Échap</kbd>
|
||||
fermer
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-wider text-concrete/50">
|
||||
<Command size={10} />
|
||||
<span>Momento Search</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user