484 lines
16 KiB
TypeScript
484 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type KeyboardEvent,
|
|
} from "react"
|
|
import { useRouter, useSearchParams, usePathname } from "next/navigation"
|
|
import {
|
|
Search,
|
|
SlidersHorizontal,
|
|
X,
|
|
Paperclip,
|
|
Clock,
|
|
User,
|
|
} from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { cn } from "@/lib/utils"
|
|
import { emails } from "@/lib/email-data"
|
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
|
import { useActiveAccount } from "@/lib/stores/account-store"
|
|
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
|
|
import {
|
|
matchContacts,
|
|
matchEmails,
|
|
bestCompletion,
|
|
type SearchSuggestion,
|
|
} from "@/lib/mail-search/search-engine"
|
|
import {
|
|
parseSearchParams,
|
|
} from "@/lib/mail-search/search-params"
|
|
import {
|
|
buildQuickSearchParams,
|
|
submitMailSearch,
|
|
} from "@/lib/mail-search/navigate"
|
|
import { MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS } from "@/lib/mail-chrome-classes"
|
|
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
|
|
|
interface MailSearchBarProps {
|
|
className?: string
|
|
compact?: boolean
|
|
}
|
|
|
|
import { AdvancedSearchPanel } from "@/components/gmail/mail-search/advanced-search-panel"
|
|
|
|
// ─── Main Search Bar ─────────────────────────────────────────────────────────
|
|
|
|
export function MailSearchBar({
|
|
className,
|
|
compact = false,
|
|
}: MailSearchBarProps) {
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
const isOnSearchPage = pathname?.includes("/mail/search") ?? false
|
|
const urlSearchParams = useSearchParams()
|
|
const currentSearchParams = useMemo(
|
|
() => parseSearchParams(urlSearchParams),
|
|
[urlSearchParams]
|
|
)
|
|
const account = useActiveAccount()
|
|
const contacts = useContactsStore((s) => s.contacts)
|
|
|
|
const inputValue = useMailSearchStore((s) => s.inputValue)
|
|
const dropdownOpen = useMailSearchStore((s) => s.dropdownOpen)
|
|
const selectedIndex = useMailSearchStore((s) => s.selectedIndex)
|
|
const advancedOpen = useMailSearchStore((s) => s.advancedOpen)
|
|
const chipAttachment = useMailSearchStore((s) => s.chipAttachment)
|
|
const chipLast7Days = useMailSearchStore((s) => s.chipLast7Days)
|
|
const chipFromMe = useMailSearchStore((s) => s.chipFromMe)
|
|
|
|
const {
|
|
setInputValue,
|
|
setDropdownOpen,
|
|
setSelectedIndex,
|
|
setAdvancedOpen,
|
|
toggleChipAttachment,
|
|
toggleChipLast7Days,
|
|
toggleChipFromMe,
|
|
reset,
|
|
} = useMailSearchStore.getState()
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const [focused, setFocused] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const q = currentSearchParams?.q ?? ""
|
|
if (q && !inputValue) {
|
|
setInputValue(q)
|
|
}
|
|
}, [currentSearchParams?.q])
|
|
|
|
const suggestions = useMemo<SearchSuggestion[]>(() => {
|
|
if (!inputValue.trim()) return []
|
|
const contactHits = matchContacts(inputValue, contacts, 5)
|
|
const emailHits = matchEmails(inputValue, emails, 5)
|
|
const seen = new Set(contactHits.map((c) => c.email))
|
|
const unique = emailHits.filter((e) => !seen.has(e.email))
|
|
return [...contactHits, ...unique]
|
|
}, [inputValue, contacts])
|
|
|
|
const ghostText = useMemo(
|
|
() => bestCompletion(inputValue, suggestions),
|
|
[inputValue, suggestions]
|
|
)
|
|
|
|
const totalItems = suggestions.length + 1
|
|
|
|
const submitSearch = useCallback(
|
|
(overrideQuery?: string) => {
|
|
const q = overrideQuery ?? inputValue
|
|
const params = buildQuickSearchParams(q, {
|
|
chipAttachment,
|
|
chipLast7Days,
|
|
chipFromMe,
|
|
fromEmail: account.email,
|
|
})
|
|
if (!Object.keys(params).length) return
|
|
submitMailSearch(router, params, {
|
|
onAfter: () => {
|
|
setDropdownOpen(false)
|
|
inputRef.current?.blur()
|
|
},
|
|
})
|
|
},
|
|
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router]
|
|
)
|
|
|
|
const selectSuggestion = useCallback(
|
|
(s: SearchSuggestion) => {
|
|
const params = buildQuickSearchParams(s.email, {
|
|
chipAttachment,
|
|
chipLast7Days,
|
|
chipFromMe,
|
|
fromEmail: account.email,
|
|
})
|
|
submitMailSearch(router, params, {
|
|
onAfter: () => {
|
|
setInputValue(s.email)
|
|
setDropdownOpen(false)
|
|
inputRef.current?.blur()
|
|
},
|
|
})
|
|
},
|
|
[chipAttachment, chipLast7Days, chipFromMe, account.email, router]
|
|
)
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
if (!dropdownOpen && e.key !== "Enter") return
|
|
|
|
switch (e.key) {
|
|
case "ArrowDown":
|
|
e.preventDefault()
|
|
setSelectedIndex(
|
|
selectedIndex < totalItems - 1 ? selectedIndex + 1 : 0
|
|
)
|
|
break
|
|
case "ArrowUp":
|
|
e.preventDefault()
|
|
setSelectedIndex(
|
|
selectedIndex > 0 ? selectedIndex - 1 : totalItems - 1
|
|
)
|
|
break
|
|
case "Enter":
|
|
e.preventDefault()
|
|
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
|
selectSuggestion(suggestions[selectedIndex]!)
|
|
} else {
|
|
submitSearch()
|
|
}
|
|
break
|
|
case "Tab":
|
|
if (ghostText) {
|
|
e.preventDefault()
|
|
setInputValue(inputValue + ghostText)
|
|
}
|
|
break
|
|
case "Escape":
|
|
e.preventDefault()
|
|
setDropdownOpen(false)
|
|
setAdvancedOpen(false)
|
|
inputRef.current?.blur()
|
|
break
|
|
}
|
|
},
|
|
[
|
|
dropdownOpen,
|
|
selectedIndex,
|
|
totalItems,
|
|
suggestions,
|
|
ghostText,
|
|
inputValue,
|
|
submitSearch,
|
|
selectSuggestion,
|
|
]
|
|
)
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
const target = e.target as HTMLElement | null
|
|
if (!target) return
|
|
if (containerRef.current?.contains(target)) return
|
|
if (target.closest("[data-radix-popper-content-wrapper]")) return
|
|
setDropdownOpen(false)
|
|
setAdvancedOpen(false)
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside)
|
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
}, [])
|
|
|
|
const showDropdown = dropdownOpen && inputValue.trim().length > 0 && focused
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn("relative flex w-full min-w-0 flex-col overflow-visible", className)}
|
|
>
|
|
{/* Input row */}
|
|
<div className="relative flex w-full min-w-0 items-center text-[#5f6368] dark:text-[#9aa0a6]">
|
|
<div
|
|
className={cn(
|
|
"pointer-events-none absolute flex items-center",
|
|
compact ? "left-4" : "left-3.5"
|
|
)}
|
|
>
|
|
<Search className="size-5 shrink-0" />
|
|
</div>
|
|
|
|
{/* Ghost text overlay */}
|
|
{ghostText && focused && (
|
|
<div
|
|
className={cn(
|
|
"pointer-events-none absolute flex items-center text-sm text-gray-400",
|
|
compact ? "left-[44px]" : "left-[44px]"
|
|
)}
|
|
aria-hidden
|
|
>
|
|
<span className="invisible">{inputValue}</span>
|
|
<span>{ghostText}</span>
|
|
</div>
|
|
)}
|
|
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder="Rechercher dans les messages"
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onFocus={() => {
|
|
setFocused(true)
|
|
if (inputValue.trim()) setDropdownOpen(true)
|
|
}}
|
|
onBlur={() => setFocused(false)}
|
|
onKeyDown={handleKeyDown}
|
|
className={cn(
|
|
"h-12 w-full rounded-full border-0 bg-muted text-sm text-foreground outline-none placeholder-shown:text-inherit placeholder:opacity-100",
|
|
focused || advancedOpen
|
|
? "bg-white shadow-md ring-1 ring-gray-300 dark:bg-gray-900 dark:ring-gray-600"
|
|
: "",
|
|
compact ? "pl-11 pr-20" : "pl-11 pr-20"
|
|
)}
|
|
role="combobox"
|
|
aria-expanded={showDropdown}
|
|
aria-autocomplete="list"
|
|
aria-controls="search-suggestions"
|
|
autoComplete="off"
|
|
/>
|
|
|
|
{/* Clear button */}
|
|
{inputValue && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute right-10 text-gray-600"
|
|
onClick={() => {
|
|
reset()
|
|
if (isOnSearchPage) {
|
|
router.push("/mail/inbox")
|
|
} else {
|
|
inputRef.current?.focus()
|
|
}
|
|
}}
|
|
aria-label="Effacer la recherche"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</Button>
|
|
)}
|
|
|
|
{/* Advanced settings button */}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(
|
|
"absolute text-gray-600",
|
|
compact ? "right-3" : "right-2"
|
|
)}
|
|
aria-label="Filtres de recherche"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => {
|
|
const current = useMailSearchStore.getState().advancedOpen
|
|
setAdvancedOpen(!current)
|
|
}}
|
|
>
|
|
<SlidersHorizontal className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Dropdown */}
|
|
{showDropdown && !advancedOpen && (
|
|
<div
|
|
id="search-suggestions"
|
|
role="listbox"
|
|
className={MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS}
|
|
>
|
|
{/* Filter chips */}
|
|
<div className="flex items-center gap-2 overflow-x-auto border-b border-gray-100 px-4 py-2 whitespace-nowrap dark:border-gray-800">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
toggleChipAttachment()
|
|
}}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors",
|
|
chipAttachment
|
|
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
|
|
: "border-gray-200 text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
|
|
)}
|
|
>
|
|
<Paperclip className="size-3.5" />
|
|
Contient une pièce jointe
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
toggleChipLast7Days()
|
|
}}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors",
|
|
chipLast7Days
|
|
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
|
|
: "border-gray-200 text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
|
|
)}
|
|
>
|
|
<Clock className="size-3.5" />
|
|
7 derniers jours
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
toggleChipFromMe()
|
|
}}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors",
|
|
chipFromMe
|
|
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
|
|
: "border-gray-200 text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
|
|
)}
|
|
>
|
|
<User className="size-3.5" />
|
|
De moi
|
|
</button>
|
|
</div>
|
|
|
|
{/* Suggestions */}
|
|
{suggestions.map((s, i) => {
|
|
const isSelected = i === selectedIndex
|
|
if (s.kind === "contact") {
|
|
const initial = senderInitial(s.displayName)
|
|
const color = avatarColor(s.displayName)
|
|
return (
|
|
<button
|
|
key={`c-${s.contact.id}-${s.email}`}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={isSelected}
|
|
className={cn(
|
|
"flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors",
|
|
isSelected
|
|
? "bg-gray-100 dark:bg-gray-800"
|
|
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
|
)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => selectSuggestion(s)}
|
|
onMouseEnter={() => setSelectedIndex(i)}
|
|
>
|
|
<div
|
|
className="flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
|
|
style={{ backgroundColor: color }}
|
|
>
|
|
{initial}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate font-medium text-gray-900 dark:text-gray-100">
|
|
{s.displayName}
|
|
</div>
|
|
<div className="truncate text-xs text-gray-500">
|
|
{s.email}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|
|
return (
|
|
<button
|
|
key={`e-${s.email}`}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={isSelected}
|
|
className={cn(
|
|
"flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors",
|
|
isSelected
|
|
? "bg-gray-100 dark:bg-gray-800"
|
|
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
|
)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => selectSuggestion(s)}
|
|
onMouseEnter={() => setSelectedIndex(i)}
|
|
>
|
|
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-gray-200 text-gray-500 dark:bg-gray-700">
|
|
<User className="size-4" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-gray-700 dark:text-gray-300">
|
|
{s.email}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
|
|
{/* "All results" row */}
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={selectedIndex === suggestions.length}
|
|
className={cn(
|
|
"flex w-full items-center gap-3 border-t border-gray-100 px-4 py-2.5 text-left text-sm transition-colors dark:border-gray-800",
|
|
selectedIndex === suggestions.length
|
|
? "bg-gray-100 dark:bg-gray-800"
|
|
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
|
)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => submitSearch()}
|
|
onMouseEnter={() => setSelectedIndex(suggestions.length)}
|
|
>
|
|
<div className="flex size-8 shrink-0 items-center justify-center">
|
|
<Search className="size-5 text-gray-400" />
|
|
</div>
|
|
<span className="text-gray-600 dark:text-gray-400">
|
|
Tous les résultats de recherche pour «
|
|
<span className="font-medium text-gray-900 dark:text-gray-100">
|
|
{inputValue}
|
|
</span>
|
|
»
|
|
</span>
|
|
<span className="ml-auto text-xs text-gray-400">
|
|
Appuyer sur ENTRÉE
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Advanced search panel */}
|
|
{advancedOpen && (
|
|
<AdvancedSearchPanel
|
|
onClose={() => setAdvancedOpen(false)}
|
|
initialQuery={inputValue}
|
|
currentParams={currentSearchParams}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|