ultisuite-client/components/gmail/mobile-search-overlay.tsx
2026-05-20 16:01:08 +02:00

570 lines
20 KiB
TypeScript

"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react"
import { useRouter } from "next/navigation"
import {
ArrowLeft,
Search,
X,
Paperclip,
Clock,
User,
SlidersHorizontal,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
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 {
matchContacts,
matchEmails,
bestCompletion,
type SearchSuggestion,
} from "@/lib/mail-search/search-engine"
import {
buildSearchUrl,
EMPTY_SEARCH_PARAMS,
DATE_RANGE_OPTIONS,
SEARCH_IN_OPTIONS,
type SearchParams,
} from "@/lib/mail-search/search-params"
import {
buildQuickSearchParams,
submitMailSearch,
} from "@/lib/mail-search/navigate"
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import {
MAIL_MOBILE_SEARCH_SHEET_CLASS,
MAIL_SEARCH_CHECKBOX_CLASS,
MAIL_SEARCH_CHIP_INACTIVE_CLASS,
MAIL_SEARCH_FIELD_CLASS,
MAIL_SEARCH_SECTION_DIVIDER_CLASS,
MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS,
} from "@/lib/mail-chrome-classes"
interface MobileSearchOverlayProps {
open: boolean
onClose: () => void
initialQuery?: string
}
export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: MobileSearchOverlayProps) {
const router = useRouter()
const account = useActiveAccount()
const contacts = useContactsStore((s) => s.contacts)
const inputValue = useMailSearchStore((s) => s.inputValue)
const selectedIndex = useMailSearchStore((s) => s.selectedIndex)
const chipAttachment = useMailSearchStore((s) => s.chipAttachment)
const chipLast7Days = useMailSearchStore((s) => s.chipLast7Days)
const chipFromMe = useMailSearchStore((s) => s.chipFromMe)
const {
setInputValue,
setSelectedIndex,
toggleChipAttachment,
toggleChipLast7Days,
toggleChipFromMe,
resetChips,
reset,
} = useMailSearchStore.getState()
const [advancedMode, setAdvancedMode] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setInputValue(initialQuery)
setAdvancedMode(false)
setTimeout(() => inputRef.current?.focus(), 50)
} else {
reset()
setAdvancedMode(false)
}
}, [open, initialQuery, setInputValue, reset])
const suggestions = useMemo<SearchSuggestion[]>(() => {
if (!inputValue.trim()) return []
const contactHits = matchContacts(inputValue, contacts, 4)
const emailHits = matchEmails(inputValue, emails, 4)
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: onClose })
},
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose]
)
const selectSuggestion = useCallback(
(s: SearchSuggestion) => {
const params = buildQuickSearchParams(s.email, {
chipAttachment,
chipLast7Days,
chipFromMe,
fromEmail: account.email,
})
submitMailSearch(router, params, { onAfter: onClose })
},
[chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose]
)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
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()
onClose()
break
}
},
[selectedIndex, totalItems, suggestions, ghostText, inputValue, submitSearch, selectSuggestion, onClose]
)
return (
<Sheet open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<SheetContent
side="bottom"
hideClose
overlayClassName="z-[100] bg-black/40"
className={MAIL_MOBILE_SEARCH_SHEET_CLASS}
>
<SheetTitle className="sr-only">Rechercher dans les messages</SheetTitle>
{/* Header */}
<div
className={cn(
"flex shrink-0 items-center gap-2 border-b bg-mail-surface-elevated px-2 py-2",
MAIL_SEARCH_SECTION_DIVIDER_CLASS
)}
>
<Button
variant="ghost"
size="icon"
className="size-10 shrink-0 text-muted-foreground"
onClick={() => { if (advancedMode) setAdvancedMode(false); else onClose() }}
aria-label="Retour"
>
<ArrowLeft className="size-5" />
</Button>
<div className="relative flex min-w-0 flex-1 items-center">
{ghostText && !advancedMode && (
<div className="pointer-events-none absolute left-0 flex items-center text-sm text-muted-foreground" aria-hidden>
<span className="invisible">{inputValue}</span>
<span>{ghostText}</span>
</div>
)}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
setSelectedIndex(-1)
if (advancedMode) setAdvancedMode(false)
}}
onKeyDown={handleKeyDown}
placeholder="Rechercher dans les messages"
className="h-10 w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
autoComplete="off"
/>
</div>
{inputValue && !advancedMode && (
<Button
variant="ghost"
size="icon"
className="size-10 shrink-0 text-muted-foreground"
onClick={() => {
setInputValue("")
inputRef.current?.focus()
}}
aria-label="Effacer"
>
<X className="size-5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-10 shrink-0 text-muted-foreground"
onClick={() => setAdvancedMode(!advancedMode)}
aria-label="Recherche avancée"
>
<SlidersHorizontal className="size-5" />
</Button>
</div>
{advancedMode ? (
<MobileAdvancedSearch
initialQuery={inputValue}
onSubmit={(url) => { router.push(url); onClose() }}
/>
) : (
<div className={cn("flex min-h-0 flex-1 flex-col", MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS)}>
{/* Filter chips */}
<div
className={cn(
"flex items-center gap-2 overflow-x-auto border-b px-4 py-2",
MAIL_SEARCH_SECTION_DIVIDER_CLASS
)}
>
<button
type="button"
onClick={() => toggleChipAttachment()}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 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"
: MAIL_SEARCH_CHIP_INACTIVE_CLASS
)}
>
<Paperclip className="size-3.5" />
Contient une pièce jointe
</button>
<button
type="button"
onClick={() => toggleChipLast7Days()}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 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"
: MAIL_SEARCH_CHIP_INACTIVE_CLASS
)}
>
<Clock className="size-3.5" />
7 derniers jours
</button>
<button
type="button"
onClick={() => toggleChipFromMe()}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 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"
: MAIL_SEARCH_CHIP_INACTIVE_CLASS
)}
>
<User className="size-3.5" />
De moi
</button>
</div>
{/* Suggestions */}
<div className="min-h-0 flex-1 overflow-y-auto">
{inputValue.trim() && (
<>
{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"
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-mail-nav-hover",
isSelected && "bg-mail-nav-hover"
)}
onClick={() => selectSuggestion(s)}
>
<div
className="flex size-9 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-foreground">
{s.displayName}
</div>
<div className="truncate text-xs text-muted-foreground">
{s.email}
</div>
</div>
</button>
)
}
return (
<button
key={`e-${s.email}`}
type="button"
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-mail-nav-hover",
isSelected && "bg-mail-nav-hover"
)}
onClick={() => selectSuggestion(s)}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-mail-surface-muted text-muted-foreground">
<User className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-foreground">
{s.email}
</div>
</div>
</button>
)
})}
{/* All results row */}
<button
type="button"
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-mail-nav-hover",
selectedIndex === suggestions.length && "bg-mail-nav-hover"
)}
onClick={() => submitSearch()}
>
<div className="flex size-9 shrink-0 items-center justify-center">
<Search className="size-5 text-muted-foreground" />
</div>
<span className="text-muted-foreground">
Tous les résultats pour «&nbsp;
<span className="font-medium text-foreground">
{inputValue}
</span>
&nbsp;»
</span>
</button>
</>
)}
</div>
</div>
)}
</SheetContent>
</Sheet>
)
}
// ─── Mobile Advanced Search ──────────────────────────────────────────────────
function MobileAdvancedSearch({
initialQuery,
onSubmit,
}: {
initialQuery: string
onSubmit: (url: string) => void
}) {
const [from, setFrom] = useState("")
const [to, setTo] = useState("")
const [subject, setSubject] = useState("")
const [hasWords, setHasWords] = useState(initialQuery)
const [doesNotHave, setDoesNotHave] = useState("")
const [sizeVal, setSizeVal] = useState("")
const [sizeOp, setSizeOp] = useState<"gt" | "lt">("gt")
const [sizeUnit, setSizeUnit] = useState<"Mo" | "Ko">("Mo")
const [within, setWithin] = useState("")
const [searchIn, setSearchIn] = useState("all")
const [hasAttachment, setHasAttachment] = useState(false)
const [excludeChats, setExcludeChats] = useState(false)
const handleSubmit = () => {
const params: Partial<SearchParams> = {
...EMPTY_SEARCH_PARAMS,
q: "",
from,
to,
subject,
hasWords,
doesNotHave,
size: sizeVal,
sizeOp,
sizeUnit,
within,
in: searchIn,
has: hasAttachment ? ["attachment"] : [],
excludeChats,
}
onSubmit(buildSearchUrl(params))
}
return (
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">De</Label>
<Input
value={from}
onChange={(e) => setFrom(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">À</Label>
<Input
value={to}
onChange={(e) => setTo(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Objet</Label>
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Contient les mots</Label>
<Input
value={hasWords}
onChange={(e) => setHasWords(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Ne contient pas</Label>
<Input
value={doesNotHave}
onChange={(e) => setDoesNotHave(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Taille</Label>
<div className="flex items-center gap-2">
<Select value={sizeOp} onValueChange={(v) => setSizeOp(v as "gt" | "lt")}>
<SelectTrigger className={cn("h-9 flex-1 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<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={cn("h-9 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
<Select value={sizeUnit} onValueChange={(v) => setSizeUnit(v as "Mo" | "Ko")}>
<SelectTrigger className={cn("h-9 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Mo">Mo</SelectItem>
<SelectItem value="Ko">Ko</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Plage de dates</Label>
<Select value={within} onValueChange={setWithin}>
<SelectTrigger className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue placeholder="Sélectionner" />
</SelectTrigger>
<SelectContent>
{DATE_RANGE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Rechercher dans</Label>
<Select value={searchIn} onValueChange={setSearchIn}>
<SelectTrigger className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEARCH_IN_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3 pt-1">
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
className={MAIL_SEARCH_CHECKBOX_CLASS}
checked={hasAttachment}
onCheckedChange={(v) => setHasAttachment(v === true)}
/>
Contenant une pièce jointe
</label>
<label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
className={MAIL_SEARCH_CHECKBOX_CLASS}
checked={excludeChats}
onCheckedChange={(v) => setExcludeChats(v === true)}
/>
Ne pas inclure les chats
</label>
</div>
<Button
className="w-full bg-[#1a73e8] text-sm text-white hover:bg-[#1765cc]"
onClick={handleSubmit}
>
Rechercher
</Button>
</div>
</div>
)
}