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.
744 lines
30 KiB
TypeScript
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(/&/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 { 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>
|
|
)
|
|
}
|