Some checks failed
E2E / Playwright e2e (push) Has been cancelled
Move mail, compose, contacts, and accounts off mocks onto REST + WS. Add client, auth store, IDB-backed query cache, offline queue, and sync bar; hybrid Zustand for UI-only state. Settings still local until backend has preferences API.
189 lines
5.8 KiB
TypeScript
189 lines
5.8 KiB
TypeScript
"use client"
|
|
|
|
import { useRef, useEffect, useMemo } from "react"
|
|
import Link from "next/link"
|
|
import { Search, ExternalLink, X, Plus } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
|
import { searchContacts } from "@/lib/contacts/fuzzy-search"
|
|
import { fullContactDisplayName } from "@/lib/contacts/types"
|
|
import {
|
|
CONTACTS_PANEL_CREATE_ROW_CLASS,
|
|
CONTACTS_PANEL_HEADER_CLASS,
|
|
CONTACTS_PANEL_HEADER_SEARCH_CLASS,
|
|
CONTACTS_PANEL_ICON_BTN_CLASS,
|
|
CONTACTS_PANEL_LETTER_CLASS,
|
|
CONTACTS_PANEL_LINK_TEXT_CLASS,
|
|
CONTACTS_PANEL_MUTED_ICON_CLASS,
|
|
CONTACTS_PANEL_SEARCH_INPUT_CLASS,
|
|
CONTACTS_PANEL_SECTION_LABEL_CLASS,
|
|
CONTACTS_PANEL_SHELL_CLASS,
|
|
} from "@/lib/contacts-chrome-classes"
|
|
import { cn } from "@/lib/utils"
|
|
import { ContactRow } from "./contact-row"
|
|
import { ContactsPanelLogo } from "./contacts-panel-logo"
|
|
|
|
export function ContactsListView() {
|
|
const {
|
|
searchMode,
|
|
searchQuery,
|
|
setSearchMode,
|
|
setSearchQuery,
|
|
setView,
|
|
showContactsList,
|
|
closePanel,
|
|
} = useContactsStore()
|
|
const { contacts } = useContactsList()
|
|
|
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (searchMode) {
|
|
searchInputRef.current?.focus()
|
|
}
|
|
}, [searchMode])
|
|
|
|
const filteredContacts = useMemo(() => {
|
|
if (searchMode && searchQuery) {
|
|
return searchContacts(contacts, searchQuery)
|
|
}
|
|
return contacts
|
|
}, [contacts, searchMode, searchQuery])
|
|
|
|
const groupedContacts = useMemo(() => {
|
|
const sorted = [...filteredContacts].sort((a, b) => {
|
|
const nameA = fullContactDisplayName(a) || a.emails[0]?.value || ""
|
|
const nameB = fullContactDisplayName(b) || b.emails[0]?.value || ""
|
|
return nameA.localeCompare(nameB, "fr")
|
|
})
|
|
|
|
const normalize = (ch: string) =>
|
|
ch.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toUpperCase() || "?"
|
|
|
|
const groups: { letter: string; items: typeof sorted }[] = []
|
|
for (const contact of sorted) {
|
|
const name = fullContactDisplayName(contact) || contact.emails[0]?.value || "?"
|
|
const letter = normalize(name.charAt(0))
|
|
const last = groups[groups.length - 1]
|
|
if (last && last.letter === letter) {
|
|
last.items.push(contact)
|
|
} else {
|
|
groups.push({ letter, items: [contact] })
|
|
}
|
|
}
|
|
return groups
|
|
}, [filteredContacts])
|
|
|
|
function exitSearch() {
|
|
showContactsList()
|
|
}
|
|
|
|
if (searchMode) {
|
|
return (
|
|
<div className={CONTACTS_PANEL_SHELL_CLASS}>
|
|
<div className={cn(CONTACTS_PANEL_HEADER_SEARCH_CLASS, "gap-2")}>
|
|
<ContactsPanelLogo onClick={exitSearch} compact className="-ml-1 shrink-0" />
|
|
<Search className={`h-4 w-4 shrink-0 ${CONTACTS_PANEL_MUTED_ICON_CLASS}`} />
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Recherche..."
|
|
className={CONTACTS_PANEL_SEARCH_INPUT_CLASS}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 rounded-full"
|
|
onClick={exitSearch}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<CreateContactButton onClick={() => setView("create")} />
|
|
{filteredContacts.map((contact) => (
|
|
<ContactRow
|
|
key={contact.id}
|
|
contact={contact}
|
|
onClick={() => setView("view", contact.id)}
|
|
/>
|
|
))}
|
|
</ScrollArea>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={CONTACTS_PANEL_SHELL_CLASS}>
|
|
<div className={CONTACTS_PANEL_HEADER_CLASS}>
|
|
<ContactsPanelLogo onClick={showContactsList} className="-ml-1" />
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={CONTACTS_PANEL_ICON_BTN_CLASS}
|
|
onClick={() => setSearchMode(true)}
|
|
>
|
|
<Search className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={CONTACTS_PANEL_ICON_BTN_CLASS}
|
|
asChild
|
|
>
|
|
<Link href="/contacts">
|
|
<ExternalLink className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={CONTACTS_PANEL_ICON_BTN_CLASS}
|
|
onClick={closePanel}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<CreateContactButton onClick={() => setView("create")} />
|
|
<div className={CONTACTS_PANEL_SECTION_LABEL_CLASS}>
|
|
Contacts ({contacts.length})
|
|
</div>
|
|
{groupedContacts.map((group) => (
|
|
<div key={group.letter}>
|
|
<div className={CONTACTS_PANEL_LETTER_CLASS}>{group.letter}</div>
|
|
{group.items.map((contact) => (
|
|
<ContactRow
|
|
key={contact.id}
|
|
contact={contact}
|
|
onClick={() => setView("view", contact.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
</ScrollArea>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CreateContactButton({ onClick }: { onClick: () => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={CONTACTS_PANEL_CREATE_ROW_CLASS}
|
|
>
|
|
<div className="flex h-10 w-10 items-center justify-center">
|
|
<Plus className="h-5 w-5 text-primary" />
|
|
</div>
|
|
<span className={CONTACTS_PANEL_LINK_TEXT_CLASS}>Créer un contact</span>
|
|
</button>
|
|
)
|
|
}
|