ultisuite-client/components/gmail/mail-search-bar.tsx
R3D347HR4Y aad897b617 Huhu
2026-05-20 14:06:44 +02:00

705 lines
25 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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
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 {
buildSearchUrl,
parseSearchParams,
EMPTY_SEARCH_PARAMS,
DATE_RANGE_OPTIONS,
SEARCH_IN_OPTIONS,
type SearchParams,
} from "@/lib/mail-search/search-params"
import { avatarColor, senderInitial } from "@/lib/sender-display"
interface MailSearchBarProps {
className?: string
compact?: boolean
}
// ─── Advanced Search Panel ───────────────────────────────────────────────────
function AdvancedSearchPanel({
onClose,
initialQuery,
currentParams,
}: {
onClose: () => void
initialQuery: string
currentParams: SearchParams | null
}) {
const router = useRouter()
const [from, setFrom] = useState(currentParams?.from ?? "")
const [to, setTo] = useState(currentParams?.to ?? "")
const [subject, setSubject] = useState(currentParams?.subject ?? "")
const [hasWords, setHasWords] = useState(
currentParams?.hasWords || currentParams?.q || initialQuery
)
const [doesNotHave, setDoesNotHave] = useState(currentParams?.doesNotHave ?? "")
const [sizeVal, setSizeVal] = useState(currentParams?.size ?? "")
const [sizeOp, setSizeOp] = useState<"gt" | "lt">(currentParams?.sizeOp ?? "gt")
const [sizeUnit, setSizeUnit] = useState<"Mo" | "Ko">(currentParams?.sizeUnit ?? "Mo")
const [within, setWithin] = useState(currentParams?.within ?? "")
const [dateAfter, setDateAfter] = useState(currentParams?.after ?? "")
const [searchIn, setSearchIn] = useState(currentParams?.in ?? "all")
const [hasAttachment, setHasAttachment] = useState(
currentParams?.has?.includes("attachment") ?? false
)
const [excludeChats, setExcludeChats] = useState(currentParams?.excludeChats ?? false)
const handleSubmit = () => {
const params: Partial<SearchParams> = {
...EMPTY_SEARCH_PARAMS,
q: "",
from,
to,
subject,
hasWords,
doesNotHave,
size: sizeVal,
sizeOp,
sizeUnit,
within,
after: dateAfter,
in: searchIn,
has: hasAttachment ? ["attachment"] : [],
excludeChats,
}
router.push(buildSearchUrl(params))
onClose()
}
return (
<div
className={cn(
"absolute left-0 top-full z-50 mt-1 max-h-[80vh] overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900",
"sm:min-w-[34rem] sm:max-w-[min(42rem,calc(100vw-5rem))]",
"md:min-w-[38rem]",
"lg:right-0 lg:min-w-0 lg:max-w-none"
)}
>
<div className="space-y-3 p-4">
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
De
</Label>
<Input
value={from}
onChange={(e) => setFrom(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
autoFocus
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
À
</Label>
<Input
value={to}
onChange={(e) => setTo(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
Objet
</Label>
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
Contient les mots
</Label>
<Input
value={hasWords}
onChange={(e) => setHasWords(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
Ne contient pas
</Label>
<Input
value={doesNotHave}
onChange={(e) => setDoesNotHave(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
/>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
Taille
</Label>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Select value={sizeOp} onValueChange={(v) => setSizeOp(v as "gt" | "lt")}>
<SelectTrigger className="h-8 w-32 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gt">supérieure à</SelectItem>
<SelectItem value="lt">inférieure à</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
value={sizeVal}
onChange={(e) => setSizeVal(e.target.value)}
className="h-8 w-20 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
/>
<Select value={sizeUnit} onValueChange={(v) => setSizeUnit(v as "Mo" | "Ko")}>
<SelectTrigger className="h-8 w-20 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Mo">Mo</SelectItem>
<SelectItem value="Ko">Ko</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
Plage de dates
</Label>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Select value={within} onValueChange={setWithin}>
<SelectTrigger className="h-8 w-32 text-sm">
<SelectValue placeholder="Sélectionner" />
</SelectTrigger>
<SelectContent>
{DATE_RANGE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="date"
value={dateAfter}
onChange={(e) => setDateAfter(e.target.value)}
className="h-8 min-w-0 flex-1 rounded border border-gray-300 bg-transparent px-2 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600"
/>
</div>
</div>
<div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
Rechercher
</Label>
<Select value={searchIn} onValueChange={setSearchIn}>
<SelectTrigger className="h-8 flex-1 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEARCH_IN_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-6 pt-1">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Checkbox
checked={hasAttachment}
onCheckedChange={(v) => setHasAttachment(v === true)}
/>
Contenant une pièce jointe
</label>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<Checkbox
checked={excludeChats}
onCheckedChange={(v) => setExcludeChats(v === true)}
/>
Ne pas inclure les chats
</label>
</div>
<div className="flex items-center justify-end gap-3 border-t border-gray-100 pt-3 dark:border-gray-800">
<Button variant="ghost" className="text-sm text-blue-600" disabled>
Créer un filtre
</Button>
<Button
className="bg-[#1a73e8] text-sm text-white hover:bg-[#1765cc]"
onClick={handleSubmit}
>
Rechercher
</Button>
</div>
</div>
</div>
)
}
// ─── 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
if (!q.trim() && !chipAttachment && !chipLast7Days && !chipFromMe) return
const params: Partial<SearchParams> = { q: q.trim() }
if (chipAttachment) params.has = ["attachment"]
if (chipLast7Days) params.within = "1w"
if (chipFromMe) params.from = account.email
router.push(buildSearchUrl(params))
setDropdownOpen(false)
inputRef.current?.blur()
},
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router]
)
const selectSuggestion = useCallback(
(s: SearchSuggestion) => {
const params: Partial<SearchParams> = { q: s.email }
if (chipAttachment) params.has = ["attachment"]
if (chipLast7Days) params.within = "1w"
if (chipFromMe) params.from = account.email
router.push(buildSearchUrl(params))
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">
<div
className={cn(
"pointer-events-none absolute flex items-center text-gray-500",
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 outline-none placeholder:text-gray-500",
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="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
>
{/* 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 &laquo;&nbsp;
<span className="font-medium text-gray-900 dark:text-gray-100">
{inputValue}
</span>
&nbsp;&raquo;
</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>
)
}