182 lines
6.3 KiB
TypeScript
182 lines
6.3 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useRef, useState } from "react"
|
|
import { X } from "lucide-react"
|
|
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
|
import { Input } from "@/components/ui/input"
|
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
|
import type { AgendaEventAttendee } from "@/lib/agenda/agenda-types"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
|
|
interface Suggestion {
|
|
email: string
|
|
name: string
|
|
avatarUrl?: string
|
|
}
|
|
|
|
/** Champ « Ajouter des invités » avec autocomplétion sur le carnet d'adresses. */
|
|
export function AgendaGuestPicker({
|
|
attendees,
|
|
onChange,
|
|
organizerEmail,
|
|
}: {
|
|
attendees: AgendaEventAttendee[]
|
|
onChange: (attendees: AgendaEventAttendee[]) => void
|
|
organizerEmail?: string
|
|
}) {
|
|
const [query, setQuery] = useState("")
|
|
const [focused, setFocused] = useState(false)
|
|
const [activeIndex, setActiveIndex] = useState(0)
|
|
const blurTimer = useRef<number | null>(null)
|
|
const { contacts } = useContactsList()
|
|
|
|
const suggestions: Suggestion[] = useMemo(() => {
|
|
const q = query.trim().toLowerCase()
|
|
if (q.length < 1) return []
|
|
const taken = new Set(attendees.map((a) => a.email.toLowerCase()))
|
|
if (organizerEmail) taken.add(organizerEmail.toLowerCase())
|
|
const out: Suggestion[] = []
|
|
for (const contact of contacts) {
|
|
const name = fullContactDisplayName(contact)
|
|
for (const { value: email } of contact.emails) {
|
|
if (!email || taken.has(email.toLowerCase())) continue
|
|
if (
|
|
name.toLowerCase().includes(q) ||
|
|
email.toLowerCase().includes(q)
|
|
) {
|
|
out.push({ email, name: name || email, avatarUrl: contact.avatarUrl })
|
|
}
|
|
}
|
|
if (out.length >= 6) break
|
|
}
|
|
return out
|
|
}, [contacts, query, attendees, organizerEmail])
|
|
|
|
const addAttendee = (s: Suggestion) => {
|
|
onChange([...attendees, { email: s.email, name: s.name, status: "NEEDS-ACTION" }])
|
|
setQuery("")
|
|
setActiveIndex(0)
|
|
}
|
|
|
|
const tryAddRaw = () => {
|
|
const email = query.trim().replace(/[,;]$/, "")
|
|
if (!EMAIL_RE.test(email)) return false
|
|
if (attendees.some((a) => a.email.toLowerCase() === email.toLowerCase())) {
|
|
setQuery("")
|
|
return true
|
|
}
|
|
addAttendee({ email, name: email })
|
|
return true
|
|
}
|
|
|
|
const showSuggestions = focused && (suggestions.length > 0 || EMAIL_RE.test(query.trim()))
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="relative">
|
|
<Input
|
|
value={query}
|
|
placeholder="Ajouter des invités"
|
|
onChange={(e) => {
|
|
setQuery(e.target.value)
|
|
setActiveIndex(0)
|
|
}}
|
|
onFocus={() => {
|
|
if (blurTimer.current) window.clearTimeout(blurTimer.current)
|
|
setFocused(true)
|
|
}}
|
|
onBlur={() => {
|
|
blurTimer.current = window.setTimeout(() => setFocused(false), 150)
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault()
|
|
setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1))
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault()
|
|
setActiveIndex((i) => Math.max(i - 1, 0))
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault()
|
|
if (suggestions[activeIndex]) addAttendee(suggestions[activeIndex])
|
|
else tryAddRaw()
|
|
} else if ((e.key === "," || e.key === ";") && query.trim()) {
|
|
e.preventDefault()
|
|
tryAddRaw()
|
|
}
|
|
}}
|
|
/>
|
|
{showSuggestions && (
|
|
<div className="absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-lg border border-border/60 bg-popover py-1 shadow-lg">
|
|
{suggestions.map((s, i) => (
|
|
<button
|
|
key={s.email}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left",
|
|
i === activeIndex ? "bg-mail-nav-hover" : "hover:bg-mail-nav-hover",
|
|
)}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault()
|
|
addAttendee(s)
|
|
}}
|
|
>
|
|
<ContactAvatar name={s.name} email={s.email} avatarUrl={s.avatarUrl} size="xs" />
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-sm">{s.name}</span>
|
|
<span className="block truncate text-xs text-muted-foreground">
|
|
{s.email}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
{suggestions.length === 0 && EMAIL_RE.test(query.trim()) && (
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-mail-nav-hover"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault()
|
|
tryAddRaw()
|
|
}}
|
|
>
|
|
Ajouter « {query.trim()} »
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{attendees.length > 0 && (
|
|
<div className="flex flex-col gap-1">
|
|
{attendees.map((a) => (
|
|
<div
|
|
key={a.email}
|
|
className="group flex items-center gap-2.5 rounded-lg px-1.5 py-1 hover:bg-mail-nav-hover"
|
|
>
|
|
<ContactAvatar name={a.name || a.email} email={a.email} size="xs" />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm">{a.name || a.email}</span>
|
|
{a.name && a.name !== a.email && (
|
|
<span className="block truncate text-xs text-muted-foreground">
|
|
{a.email}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label={`Retirer ${a.email}`}
|
|
className="rounded-full p-1 text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-black/5 dark:hover:bg-white/10"
|
|
onClick={() => onChange(attendees.filter((x) => x.email !== a.email))}
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|