Files
Momento/memento-note/components/search-modal.tsx
Antigravity 3bdbec575a feat(ui): panneau repliable, carnets plus lisibles et accueil plus clair
Harmonise la liste des notes et l’éditeur (titres, dates, retour),
rend la saisie rapide et les raccourcis de l’accueil utilisables
sur téléphone, et conserve le panneau latéral repliable.
2026-09-05 20:53:48 +00:00

744 lines
30 KiB
TypeScript

'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 { consumeRememberedSearchQuery, rememberSearchQuery } from '@/lib/search/restore-search'
import { useNotebooks } from '@/context/notebooks-context'
import { useLanguage } from '@/lib/i18n'
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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/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 { t } = useLanguage()
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 [overview, setOverview] = useState<string | null>(null)
const [overviewLoading, setOverviewLoading] = 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('memento-search-saved')
?? localStorage.getItem('momento-search-saved')
if (stored) setSavedQueries(JSON.parse(stored))
} catch {}
}, [])
// Focus input when opened
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 50)
const restored = consumeRememberedSearchQuery()
setQuery(restored)
if (!restored) {
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]
)
const openNoteFromSearch = useCallback((noteId: string) => {
rememberSearchQuery(query)
const onHome = typeof window !== 'undefined' && window.location.pathname === '/home'
const params = onHome
? new URLSearchParams(window.location.search)
: new URLSearchParams()
params.set('openNote', noteId)
params.set('from', 'search')
router.push(`/home?${params.toString()}`)
onClose()
}, [query, router, onClose])
// 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 <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 + focus trap
const modalRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!isOpen) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); onClose() }
else if (e.key === 'Tab' && modalRef.current) {
const focusable = modalRef.current.querySelectorAll<HTMLElement>('button, a, input, [tabindex]:not([tabindex="-1"])')
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
}
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]) {
openNoteFromSearch(filteredMatches[selectedIndex].noteId)
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, selectedIndex, filteredMatches, onClose, openNoteFromSearch])
// 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('memento-search-saved', JSON.stringify(next))
localStorage.removeItem('momento-search-saved')
} catch {}
return next
})
}
const handleRemoveQuery = () => {
setSavedQueries(prev => {
const next = prev.filter(q => q !== query.trim())
try {
localStorage.setItem('memento-search-saved', JSON.stringify(next))
localStorage.removeItem('momento-search-saved')
} catch {}
return next
})
}
if (!isOpen) return null
return (
<div
ref={modalRef}
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"
role="dialog"
aria-modal="true"
aria-label={t('searchModal.ariaLabel')}
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<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={t('searchModal.placeholder')}
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={t('searchModal.caseSensitive')}
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={t('searchModal.regexMode')}
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">{t('searchModal.favorites')}</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
? t('searchModal.searching')
: filteredMatches.length > 0
? `${filteredMatches.length} occurrence${filteredMatches.length > 1 ? 's' : ''} dans ${docMatchesCount} note${docMatchesCount > 1 ? 's' : ''}`
: query.trim()
? t('searchModal.noResults')
: t('searchModal.typeToSearch')}
</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>{t('searchModal.trashIncluded')}</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">
{/* AI Overview */}
{(overviewLoading || overview) && (
<div className="mb-2 rounded-xl border border-brand-accent/20 bg-brand-accent/[0.03] p-3" dir="auto">
<div className="flex items-center gap-1.5 mb-1.5">
<Sparkles size={12} className="text-brand-accent" />
<span className="text-[9px] font-bold uppercase tracking-widest text-brand-accent">
{overviewLoading ? t('searchModal.aiAnalysis') : t('searchModal.aiResponse')}
</span>
</div>
{overviewLoading ? (
<div className="flex items-center gap-2">
<Loader2 size={12} className="animate-spin text-brand-accent" />
<span className="text-[11px] text-muted-foreground">{t('searchModal.resultsSummary')}</span>
</div>
) : (
<p className="text-[12px] leading-relaxed text-foreground/80">{overview}</p>
)}
</div>
)}
{filteredMatches.map((m, idx) => {
const isSelected = idx === selectedIndex
return (
<div
key={m.id}
onClick={() => setSelectedIndex(idx)}
onDoubleClick={() => {
openNoteFromSearch(m.noteId)
}}
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() ? t('searchModal.noMatch') : t('searchModal.typeForResults')}
</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">
{t('searchModal.documentPreview')}
</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={() => {
openNoteFromSearch(activeMatch.noteId)
}}
className="px-5 py-2.5 bg-brand-accent text-white hover:bg-brand-accent/90 text-xs font-semibold rounded-xl flex items-center gap-2 transition-all shadow-sm"
>
<CornerDownRight size={13} />
<span>{t('searchModal.openInEditor')}</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">{t('searchModal.documentPreview')}</p>
<p className="text-[10px] italic opacity-60">
{t('searchModal.selectResultToPreview')}
</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>
{t('searchModal.hintNavigate')}
</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>
{t('searchModal.hintOpen')}
</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>
{t('searchModal.hintClose')}
</span>
</div>
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-wider text-concrete/50">
<Command size={10} />
<span>{t('searchModal.title')}</span>
</div>
</div>
</motion.div>
</div>
)
}