diff --git a/components/gmail/contact-hover-card.tsx b/components/gmail/contact-hover-card.tsx index 7b79164..4687bad 100644 --- a/components/gmail/contact-hover-card.tsx +++ b/components/gmail/contact-hover-card.tsx @@ -9,11 +9,10 @@ import { } from "@/components/ui/hover-card" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" +import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar" import { - avatarColor, cleanSenderName, resolveSenderEmail, - senderInitial, } from "@/lib/sender-display" import { Calendar, @@ -67,7 +66,6 @@ export function ContactHoverCard({ const name = cleanSenderName(displayName) const email = resolveSenderEmail(displayName, emailOverride) - const color = avatarColor(name) const matchedContact = useMemo( () => findContactByEmail(contacts, email), @@ -178,12 +176,12 @@ export function ContactHoverCard({ >
-
- {senderInitial(name)} -
+

{name}

{email}

diff --git a/components/gmail/contacts-page/add-coordinates-view.tsx b/components/gmail/contacts-page/add-coordinates-view.tsx index 98ccf3d..304ef8c 100644 --- a/components/gmail/contacts-page/add-coordinates-view.tsx +++ b/components/gmail/contacts-page/add-coordinates-view.tsx @@ -1,23 +1,313 @@ "use client" +import { useMemo, useState } from "react" +import { Check, Loader2, X } from "lucide-react" +import { Button } from "@/components/ui/button" import { + useAcceptEnrichmentSuggestion, + useRejectEnrichmentSuggestion, + useVisibleEnrichmentSuggestions, +} from "@/lib/api/hooks/use-contact-discovery" +import { useContactsList } from "@/lib/contacts/use-contacts-list" +import { useUpdateContact } from "@/lib/api/hooks/use-contact-mutations" +import { + FIELD_LABELS, + profileDisplayName, +} from "@/lib/contacts/discovery-utils" +import type { ApiEnrichmentSuggestion } from "@/lib/contacts/discovery-types" +import type { ApiContact } from "@/lib/api/types" +import { contactApiPath } from "@/lib/contacts/contact-api-path" +import type { FullContact } from "@/lib/contacts/types" +import { + CONTACTS_HEADING_TEXT, CONTACTS_MUTED_TEXT, + CONTACTS_DISCOVERY_FIELD_ROW_CLASS, + CONTACTS_PAGE_CARD_CLASS, CONTACTS_PAGE_SECTION_TITLE_CLASS, + CONTACTS_PRIMARY_BTN_CLASS, } from "@/lib/contacts-chrome-classes" +import { + DiscoveryCardsMasonry, + DiscoveryCardsMasonryItem, +} from "@/components/gmail/contacts-page/discovery-cards-masonry" +import { + DiscoveryFieldChips, + type ChipFieldItem, +} from "@/components/gmail/contacts-page/discovery-field-chips" import { cn } from "@/lib/utils" +const ACTION_BTN_CLASS = "h-9 rounded-full px-4 text-xs sm:text-sm" + export function AddCoordinatesView() { + const { suggestions, isLoading } = useVisibleEnrichmentSuggestions() + const acceptSuggestion = useAcceptEnrichmentSuggestion() + const rejectSuggestion = useRejectEnrichmentSuggestion() + const { contacts } = useContactsList() + const updateContact = useUpdateContact() + const [acceptingGroupKey, setAcceptingGroupKey] = useState(null) + + const grouped = useMemo(() => { + const map = new Map() + for (const s of suggestions) { + const key = s.target_contact_uid ?? s.profile_id ?? s.id + const list = map.get(key) ?? [] + list.push(s) + map.set(key, list) + } + return [...map.entries()] + }, [suggestions]) + + async function handleAccept(suggestion: ApiEnrichmentSuggestion) { + if (suggestion.suggestion_type === "enrich_contact" && suggestion.target_contact_uid) { + const contact = contacts.find((c) => c.id === suggestion.target_contact_uid) + if (contact) { + const patch = buildContactPatch(contact, suggestion) + await updateContact.mutateAsync({ + path: contactApiPath(contact), + etag: contact.etag, + contact: patch, + }) + } + } + await acceptSuggestion.mutateAsync(suggestion.id) + } + + async function handleAcceptAll(groupKey: string, group: ApiEnrichmentSuggestion[]) { + setAcceptingGroupKey(groupKey) + try { + const first = group[0] + if (first.suggestion_type === "enrich_contact" && first.target_contact_uid) { + const contact = contacts.find((c) => c.id === first.target_contact_uid) + if (contact) { + const patch = buildMergedContactPatch(contact, group) + await updateContact.mutateAsync({ + path: contactApiPath(contact), + etag: contact.etag, + contact: patch, + }) + } + } + for (const s of group) { + await acceptSuggestion.mutateAsync(s.id) + } + } finally { + setAcceptingGroupKey(null) + } + } + + const busy = acceptSuggestion.isPending || updateContact.isPending + return (

- Ajouter des coordonnées (0) + Ajouter des coordonnées ({suggestions.length})

-

- Aucune suggestion disponible -

+ {isLoading && ( +

Chargement…

+ )} + + {!isLoading && suggestions.length === 0 && ( +

+ Aucune suggestion disponible. Analysez vos e-mails depuis « Autres contacts » pour détecter des coordonnées dans les signatures. +

+ )} + + + {grouped.map(([key, group]) => ( + + handleAcceptAll(key, group)} + onReject={(id) => rejectSuggestion.mutate(id)} + busy={busy} + acceptingAll={acceptingGroupKey === key} + /> + + ))} +
) } + +function SuggestionGroup({ + suggestions, + contacts, + onAccept, + onAcceptAll, + onReject, + busy, + acceptingAll, +}: { + suggestions: ApiEnrichmentSuggestion[] + contacts: FullContact[] + onAccept: (s: ApiEnrichmentSuggestion) => void + onAcceptAll: () => void + onReject: (id: string) => void + busy: boolean + acceptingAll: boolean +}) { + const first = suggestions[0] + const profile = first.profile + const existing = first.target_contact_uid + ? contacts.find((c) => c.id === first.target_contact_uid) + : null + + const title = existing + ? `${existing.firstName} ${existing.lastName}`.trim() || existing.emails[0]?.value + : profile + ? profileDisplayName(profile) + : "Contact suggéré" + + const chipItems: ChipFieldItem[] = suggestions.map((s) => ({ + id: s.id, + fieldKey: s.field_path, + value: s.suggested_value, + })) + + return ( +
+
+
+

{title}

+ {first.suggestion_type === "enrich_contact" && ( +

+ Enrichissement · {suggestions.length} champ{suggestions.length > 1 ? "s" : ""} +

+ )} +
+
+ +
+
+ + onReject(id)} + /> + +
+ + Gérer champ par champ + +
    + {suggestions.map((s) => ( +
  • + + + {FIELD_LABELS[s.field_path] ?? s.field_path} : + {" "} + {s.suggested_value} + +
    + + +
    +
  • + ))} +
+
+
+ ) +} + +function buildContactPatch( + contact: FullContact, + suggestion: ApiEnrichmentSuggestion, +): Partial { + const { field_path, suggested_value } = suggestion + switch (field_path) { + case "full_name": + return { full_name: suggested_value } + case "company": + return { org: suggested_value } + case "job_title": + return { raw_vcard: appendVCardField(contact, "TITLE", suggested_value) } + case "phones": + return { phone: suggested_value } + case "emails": + return { email: suggested_value } + case "social_profiles": { + const type = (suggestion.suggested_label || "other").toLowerCase() + return { + raw_vcard: appendVCardSocialProfile(contact, type, suggested_value), + } + } + default: + return { raw_vcard: appendVCardField(contact, field_path.toUpperCase(), suggested_value) } + } +} + +function buildMergedContactPatch( + contact: FullContact, + suggestions: ApiEnrichmentSuggestion[], +): Partial { + const patch: Partial = {} + let vcardExtra = "" + + for (const s of suggestions) { + const part = buildContactPatch(contact, s) + if (part.full_name) patch.full_name = part.full_name + if (part.org) patch.org = part.org + if (part.phone) patch.phone = part.phone + if (part.email) patch.email = part.email + if (part.raw_vcard && part.raw_vcard !== patch.raw_vcard) { + vcardExtra += part.raw_vcard.replace(/^BEGIN:VCARD[\s\S]*?VERSION:3\.0\n/, "").replace(/\nEND:VCARD$/, "\n") + } + } + + if (vcardExtra) { + const base = `BEGIN:VCARD\nVERSION:3.0\nFN:${contact.firstName} ${contact.lastName}\n` + patch.raw_vcard = `${base}${vcardExtra}END:VCARD` + } + + return patch +} + +function appendVCardField(contact: FullContact, prop: string, value: string): string { + const base = `BEGIN:VCARD\nVERSION:3.0\nFN:${contact.firstName} ${contact.lastName}\n` + return `${base}${prop}:${value}\nEND:VCARD` +} + +function appendVCardSocialProfile(contact: FullContact, type: string, value: string): string { + const base = `BEGIN:VCARD\nVERSION:3.0\nFN:${contact.firstName} ${contact.lastName}\n` + const socialType = type === "x" ? "twitter" : type + return `${base}X-SOCIALPROFILE;TYPE=${socialType}:${value}\nEND:VCARD` +} diff --git a/components/gmail/contacts-page/blocked-contacts-view.tsx b/components/gmail/contacts-page/blocked-contacts-view.tsx new file mode 100644 index 0000000..1dd7156 --- /dev/null +++ b/components/gmail/contacts-page/blocked-contacts-view.tsx @@ -0,0 +1,148 @@ +"use client" + +import { useCallback, useMemo, useState } from "react" +import { flushSync } from "react-dom" +import { + useAddDiscoveredContact, + useBlockedDiscoveredContacts, + useRejectDiscoveredProfile, +} from "@/lib/api/hooks/use-contact-discovery" +import { fullContactToApiContact } from "@/lib/api/adapters" +import { profileDisplayName } from "@/lib/contacts/discovery-utils" +import type { ApiDiscoveredProfile, ApiDiscoveredProfileGroup } from "@/lib/contacts/discovery-types" +import type { FullContact } from "@/lib/contacts/types" +import { useContactsList } from "@/lib/contacts/use-contacts-list" +import { useBlockedSendersStore } from "@/lib/stores/blocked-senders-store" +import { + CONTACTS_HEADING_TEXT, + CONTACTS_MUTED_TEXT, + CONTACTS_PAGE_SECTION_TITLE_CLASS, +} from "@/lib/contacts-chrome-classes" +import { + DiscoveryCardsMasonry, + DiscoveryCardsMasonryItem, +} from "@/components/gmail/contacts-page/discovery-cards-masonry" +import { SuggestedContactCard } from "@/components/gmail/contacts-page/suggested-contact-card" +import { cn } from "@/lib/utils" + +interface BlockedContactsViewProps { + searchQuery: string +} + +function profileToGroup(profile: ApiDiscoveredProfile): ApiDiscoveredProfileGroup { + return { + group_key: profile.id, + profile_ids: [profile.id], + display_name: profileDisplayName(profile), + primary_email: profile.primary_email, + message_count: profile.message_count, + profile, + profiles: [profile], + } +} + +export function BlockedContactsView({ searchQuery }: BlockedContactsViewProps) { + const { bookId } = useContactsList() + const { data: profiles = [], isLoading } = useBlockedDiscoveredContacts() + const addDiscoveredContact = useAddDiscoveredContact() + const rejectProfile = useRejectDiscoveredProfile() + const unblockSender = useBlockedSendersStore((s) => s.unblockSender) + const [removedProfileIds, setRemovedProfileIds] = useState>(() => new Set()) + + const markProfileRemoved = useCallback((profileId: string) => { + setRemovedProfileIds((prev) => { + if (prev.has(profileId)) return prev + const next = new Set(prev) + next.add(profileId) + return next + }) + }, []) + + const restoreProfile = useCallback((profileId: string) => { + setRemovedProfileIds((prev) => { + if (!prev.has(profileId)) return prev + const next = new Set(prev) + next.delete(profileId) + return next + }) + }, []) + + const filtered = useMemo(() => { + const visible = profiles.filter((p) => !removedProfileIds.has(p.id)) + const q = searchQuery.trim().toLowerCase() + if (!q) return visible + return visible.filter((p) => { + const name = profileDisplayName(p).toLowerCase() + return name.includes(q) || p.primary_email.toLowerCase().includes(q) + }) + }, [profiles, searchQuery, removedProfileIds]) + + function handleAdd(profile: ApiDiscoveredProfile, buildContact: () => FullContact) { + if (!bookId) return + flushSync(() => markProfileRemoved(profile.id)) + unblockSender(profile.primary_email) + requestAnimationFrame(() => { + addDiscoveredContact.mutate( + { + bookId, + profileId: profile.id, + contact: fullContactToApiContact(buildContact()), + }, + { onError: () => restoreProfile(profile.id) }, + ) + }) + } + + function handleRemove(profile: ApiDiscoveredProfile) { + flushSync(() => markProfileRemoved(profile.id)) + unblockSender(profile.primary_email) + requestAnimationFrame(() => { + rejectProfile.mutate(profile.id, { onError: () => restoreProfile(profile.id) }) + }) + } + + const addingProfileId = + addDiscoveredContact.isPending && typeof addDiscoveredContact.variables?.profileId === "string" + ? addDiscoveredContact.variables.profileId + : null + + return ( +
+

Bloqués

+

+ Ces expéditeurs sont traités comme indésirables : pas de suggestion, contenus distants + bloqués dans les e-mails. Vous pouvez les ajouter à votre carnet ou les supprimer + définitivement. +

+ +

+ {filtered.length} expéditeur{filtered.length !== 1 ? "s" : ""} +

+ + {isLoading && ( +

Chargement…

+ )} + + {!isLoading && filtered.length === 0 && ( +

+ Aucun expéditeur bloqué. +

+ )} + + + {filtered.map((p) => ( + + handleAdd(p, buildContact)} + onRemove={() => handleRemove(p)} + /> + + ))} + +
+ ) +} diff --git a/components/gmail/contacts-page/contact-create-page.tsx b/components/gmail/contacts-page/contact-create-page.tsx index b4a742f..af29a0f 100644 --- a/components/gmail/contacts-page/contact-create-page.tsx +++ b/components/gmail/contacts-page/contact-create-page.tsx @@ -42,17 +42,17 @@ import { PopoverTrigger, } from "@/components/ui/popover" import { useContactsList } from "@/lib/contacts/use-contacts-list" +import { toast } from "sonner" import { useCreateContact, useUpdateContact } from "@/lib/api/hooks/use-contact-mutations" import { fullContactToApiContact } from "@/lib/api/adapters" +import { contactApiPath } from "@/lib/contacts/contact-api-path" import { fullContactDisplayName } from "@/lib/contacts/types" +import { ContactAvatarPicker } from "@/components/gmail/contacts/contact-avatar-picker" import type { FullContact } from "@/lib/contacts/types" -import { avatarColor, senderInitial } from "@/lib/sender-display" import { useNavStore } from "@/lib/stores/nav-store" import { cn } from "@/lib/utils" import { CONTACTS_MUTED_TEXT, - CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS, - CONTACTS_PAGE_AVATAR_PLACEHOLDER_LARGE_CLASS, CONTACTS_PAGE_ICON_BTN_CLASS, CONTACTS_PAGE_SAVE_BTN_CLASS, CONTACTS_PANEL_ADD_TAG_BTN_CLASS, @@ -103,6 +103,7 @@ const contactFormSchema = z.object({ birthday: z.object({ day: z.any().optional(), month: z.any().optional(), year: z.any().optional() }).optional(), notes: z.string().optional().default(""), labels: z.array(z.string()).optional().default([]), + avatarUrl: z.string().optional(), }) type ContactFormValues = z.infer @@ -145,6 +146,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC addresses: [], birthday: { day: undefined, month: undefined, year: undefined }, notes: "", labels: [], + avatarUrl: undefined, }, }) @@ -175,6 +177,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC birthday: existingContact.birthday ?? { day: undefined, month: undefined, year: undefined }, notes: existingContact.notes ?? "", labels: existingContact.labels ?? [], + avatarUrl: existingContact.avatarUrl, }) } }, [existingContact, reset]) @@ -182,6 +185,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC const firstName = watch("firstName") const lastName = watch("lastName") const watchedEmails = watch("emails") + const avatarUrl = watch("avatarUrl") const currentLabels = watch("labels") ?? [] const displayName = `${firstName ?? ""} ${lastName ?? ""}`.trim() @@ -210,6 +214,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC birthday: data.birthday?.day || data.birthday?.month || data.birthday?.year ? data.birthday : undefined, notes: data.notes || undefined, labels: data.labels?.length ? data.labels : undefined, + avatarUrl: data.avatarUrl || undefined, } if (mode === "create") { @@ -230,24 +235,43 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC onSuccess: (created) => { onSaved(created?.uid ?? tempId) }, + onError: (err) => { + const msg = err instanceof Error && err.message ? err.message : "Impossible d'enregistrer le contact" + toast.error(msg) + }, }, ) - } else if (contactId) { + } else if (contactId && existingContact) { const fullContact: FullContact = { id: contactId, + path: existingContact.path, + etag: existingContact.etag, ...payload, firstName: payload.firstName ?? "", lastName: payload.lastName ?? "", emails: payload.emails ?? [], phones: payload.phones ?? [], - createdAt: Date.now(), + createdAt: existingContact.createdAt, updatedAt: Date.now(), } - updateContactMutation.mutate({ - path: contactId, - contact: fullContactToApiContact(fullContact), - }) - onSaved(contactId) + if (!existingContact.etag) { + toast.error("Impossible d'enregistrer : version du contact inconnue. Rechargez la liste.") + return + } + updateContactMutation.mutate( + { + path: contactApiPath(fullContact), + etag: existingContact.etag, + contact: fullContactToApiContact(fullContact), + }, + { + onSuccess: () => onSaved(contactId), + onError: (err) => { + const msg = err instanceof Error && err.message ? err.message : "Impossible d'enregistrer les modifications" + toast.error(msg) + }, + }, + ) } } @@ -274,28 +298,13 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC {/* Avatar */}
- {displayName ? ( -
-
- {senderInitial(displayName)} -
-
- -
-
- ) : ( -
-
- -
-
- -
-
- )} + e.value?.trim())?.value} + onChange={(next) => setValue("avatarUrl", next, { shouldDirty: true })} + />
{/* Labels */} diff --git a/components/gmail/contacts-page/contact-detail-page.tsx b/components/gmail/contacts-page/contact-detail-page.tsx index 5ccb604..b95daaf 100644 --- a/components/gmail/contacts-page/contact-detail-page.tsx +++ b/components/gmail/contacts-page/contact-detail-page.tsx @@ -1,9 +1,11 @@ "use client" +import { useState } from "react" import { ArrowLeft, Download, Pencil, + Sparkles, Star, Trash2, Mail, @@ -19,8 +21,8 @@ import { Button } from "@/components/ui/button" import { useContactsStore } from "@/lib/contacts/contacts-store" import { useContactsList } from "@/lib/contacts/use-contacts-list" import { useDeleteContact } from "@/lib/api/hooks/use-contact-mutations" +import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar" import { fullContactDisplayName } from "@/lib/contacts/types" -import { avatarColor, senderInitial } from "@/lib/sender-display" import { useNavStore } from "@/lib/stores/nav-store" import { downloadContactVCard } from "@/lib/contacts/export-contacts" import { @@ -34,6 +36,9 @@ import { CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS, } from "@/lib/contacts-chrome-classes" import { cn } from "@/lib/utils" +import { useLLMSettings } from "@/lib/api/hooks/use-contact-discovery" +import { isLLMConfigured } from "@/lib/contacts/llm-settings-utils" +import { ContactImproveDialog } from "@/components/gmail/contacts-page/contact-improve-dialog" const FRENCH_MONTHS = [ "janvier", "février", "mars", "avril", "mai", "juin", @@ -59,7 +64,10 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa const softDeleteContact = useContactsStore((s) => s.softDeleteContact) const deleteContactMutation = useDeleteContact() const labelRows = useNavStore((s) => s.labelRows) + const { data: llmSettings } = useLLMSettings() + const [improveOpen, setImproveOpen] = useState(false) const contact = contacts.find((c) => c.id === contactId) + const llmReady = isLLMConfigured(llmSettings) if (!contact) { return ( @@ -71,8 +79,6 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa const displayName = fullContactDisplayName(contact) const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?" - const color = avatarColor(name) - const initial = senderInitial(name) const primaryEmail = contact.emails[0]?.value function handleDelete() { @@ -93,6 +99,22 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
+ @@ -125,16 +147,7 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
- {contact.avatarUrl ? ( - {name} - ) : ( -
- {initial} -
- )} +

{name}

{contact.company && ( @@ -159,6 +172,25 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
+
+ +
+ {primaryEmail && (
+ +
) } diff --git a/components/gmail/contacts-page/contact-improve-dialog.tsx b/components/gmail/contacts-page/contact-improve-dialog.tsx new file mode 100644 index 0000000..0fb9fdf --- /dev/null +++ b/components/gmail/contacts-page/contact-improve-dialog.tsx @@ -0,0 +1,139 @@ +"use client" + +import { useEffect, useState } from "react" +import { Loader2, Sparkles } from "lucide-react" +import { toast } from "sonner" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { useImproveContact } from "@/lib/api/hooks/use-improve-contact" +import { useUpdateContact } from "@/lib/api/hooks/use-contact-mutations" +import { fullContactToApiContact } from "@/lib/api/adapters" +import type { ApiEnrichedContactData } from "@/lib/contacts/discovery-types" +import { + fullContactToImprovePayload, + improvedDataToDraftFields, + mergeImprovedContact, +} from "@/lib/contacts/improve-contact" +import { contactApiPath } from "@/lib/contacts/contact-api-path" +import type { FullContact } from "@/lib/contacts/types" +import { DiscoveryFieldChips } from "@/components/gmail/contacts-page/discovery-field-chips" +import { + CONTACTS_MUTED_TEXT, + CONTACTS_PRIMARY_BTN_CLASS, +} from "@/lib/contacts-chrome-classes" +import { cn } from "@/lib/utils" + +interface ContactImproveDialogProps { + contact: FullContact + open: boolean + onOpenChange: (open: boolean) => void +} + +export function ContactImproveDialog({ contact, open, onOpenChange }: ContactImproveDialogProps) { + const improveMutation = useImproveContact() + const updateMutation = useUpdateContact() + const [improved, setImproved] = useState(null) + + useEffect(() => { + if (!open) { + setImproved(null) + improveMutation.reset() + return + } + const payload = fullContactToImprovePayload(contact) + improveMutation.mutate(payload, { + onSuccess: (data) => setImproved(data), + onError: (err) => { + const msg = err instanceof Error ? err.message : "Échec de l'amélioration IA" + toast.error(msg) + }, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- relancer à l'ouverture pour ce contact + }, [open, contact.id]) + + const loading = improveMutation.isPending + const chipItems = improved ? improvedDataToDraftFields(improved) : [] + + function handleApply() { + if (!improved) return + const merged = mergeImprovedContact(contact, improved) + const path = contactApiPath(contact) + if (!contact.etag) { + toast.error("Impossible d'enregistrer : version du contact inconnue. Rechargez la liste.") + return + } + updateMutation.mutate( + { path, etag: contact.etag, contact: fullContactToApiContact(merged) }, + { + onSuccess: () => { + toast.success("Contact mis à jour") + onOpenChange(false) + }, + onError: (err) => { + const msg = + err instanceof Error && err.message ? err.message : "Impossible d'enregistrer les modifications" + toast.error(msg) + }, + }, + ) + } + + return ( + + + + + + Amélioration IA + + + + {loading && ( +
+ + Analyse et nettoyage en cours… +
+ )} + + {!loading && improveMutation.isError && ( +

+ L'amélioration a échoué. Vérifiez la configuration LLM dans les réglages. +

+ )} + + {!loading && improved && ( +
+

+ Aperçu des informations nettoyées et réorganisées. Aucune donnée n'est + enregistrée tant que vous n'appliquez pas les changements. +

+ +
+ )} + + + + + +
+
+ ) +} diff --git a/components/gmail/contacts-page/contact-label-picker-block.tsx b/components/gmail/contacts-page/contact-label-picker-block.tsx new file mode 100644 index 0000000..5ccc7a9 --- /dev/null +++ b/components/gmail/contacts-page/contact-label-picker-block.tsx @@ -0,0 +1,162 @@ +"use client" + +import { useLayoutEffect, useRef, type ComponentType, type ReactNode } from "react" +import { Check, Minus, Plus } from "lucide-react" +import { Icon } from "@iconify/react" +import { Input } from "@/components/ui/input" +import { cn } from "@/lib/utils" +import type { LabelRowItem } from "@/lib/sidebar-nav-data" +import type { LabelPickerVisual } from "@/lib/label-picker-visual" +import { LabelPickerLeadingVisual } from "@/components/gmail/email-label-picker-block" + +export type ContactLabelPresence = "none" | "some" | "all" + +export type ContactLabelPickerItemComponent = ComponentType<{ + children: ReactNode + onSelect?: (event: Event) => void + className?: string +}> + +function LabelPickerCheckboxVisual({ + checked, +}: { + checked: boolean | "indeterminate" +}) { + return ( + + {checked === true ? ( + + ) : checked === "indeterminate" ? ( + + ) : null} + + ) +} + +export function ContactLabelPickerBlock({ + query, + onQueryChange, + labelRows, + resolveLabelVisual, + Item, + getLabelPresence, + onToggleLabel, + onCreateLabel, + listClassName, + searchAutoFocus = true, +}: { + query: string + onQueryChange: (v: string) => void + labelRows: LabelRowItem[] + resolveLabelVisual: (labelId: string) => LabelPickerVisual + Item: ContactLabelPickerItemComponent + getLabelPresence: (labelId: string) => ContactLabelPresence + onToggleLabel: (labelId: string) => void + onCreateLabel: (labelText: string) => void + listClassName?: string + searchAutoFocus?: boolean +}) { + const searchInputRef = useRef(null) + + useLayoutEffect(() => { + if (!searchAutoFocus) return + let inner = 0 + const outer = requestAnimationFrame(() => { + inner = requestAnimationFrame(() => { + searchInputRef.current?.focus({ preventScroll: true }) + }) + }) + return () => { + cancelAnimationFrame(outer) + if (inner) cancelAnimationFrame(inner) + } + }, [searchAutoFocus]) + + const q = query.trim().toLowerCase() + const available = labelRows.filter((r) => r.enabled !== false) + const filtered = available.filter( + (row) => q.length === 0 || row.label.toLowerCase().includes(q), + ) + const trimmed = query.trim() + const hasExact = available.some( + (row) => row.label.toLowerCase() === trimmed.toLowerCase(), + ) + const canCreate = trimmed.length > 0 && !hasExact + + return ( + <> +
e.stopPropagation()} + > + onQueryChange(e.target.value)} + placeholder="Rechercher ou créer un libellé…" + aria-label="Rechercher ou créer un libellé" + className="h-8 border-[#dadce0] text-sm shadow-none" + autoComplete="off" + onPointerDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + /> +
+
+ {canCreate ? ( + { + e.preventDefault() + onCreateLabel(trimmed) + }} + > + + + Créer le libellé « {trimmed} » + + + ) : null} + {filtered.map((row) => { + const presence = getLabelPresence(row.id) + const boxChecked: boolean | "indeterminate" = + presence === "all" ? true : presence === "some" ? "indeterminate" : false + return ( + { + e.preventDefault() + onToggleLabel(row.id) + }} + > + + {row.icon ? ( + + + + ) : ( + + )} + {row.label} + + ) + })} + {filtered.length === 0 && !canCreate ? ( +
+ Aucun libellé correspondant +
+ ) : null} +
+ + ) +} diff --git a/components/gmail/contacts-page/contacts-app-shell.tsx b/components/gmail/contacts-page/contacts-app-shell.tsx index a43ecf7..d194ae0 100644 --- a/components/gmail/contacts-page/contacts-app-shell.tsx +++ b/components/gmail/contacts-page/contacts-app-shell.tsx @@ -9,6 +9,9 @@ import { ContactsTable } from "./contacts-table" import { ContactDetailPage } from "./contact-detail-page" import { ContactCreatePage } from "./contact-create-page" import { MergeDuplicatesView } from "./merge-duplicates-view" +import { OtherContactsView } from "./other-contacts-view" +import { IgnoredContactsView } from "./ignored-contacts-view" +import { BlockedContactsView } from "./blocked-contacts-view" import { TrashView } from "./trash-view" import { BulkCreateDialog } from "./bulk-create-dialog" import { ImportDialog } from "./import-dialog" @@ -18,6 +21,8 @@ export type ContactsPageView = | "contacts" | "frequent" | "other" + | "ignored" + | "blocked" | "merge" | "import" | "trash" @@ -139,7 +144,6 @@ export function ContactsAppShell() {
{(currentView === "contacts" || currentView === "frequent" || - currentView === "other" || currentView === "label") && ( )} + {currentView === "other" && ( + + )} + {currentView === "ignored" && ( + + )} + {currentView === "blocked" && ( + + )} {currentView === "detail" && activeContactId && ( void + contacts: FullContact[] + onApply: (field: ContactBulkEditField, value: string) => void + isApplying?: boolean +} + +export function ContactsBulkEditDialog({ + open, + onOpenChange, + contacts, + onApply, + isApplying = false, +}: ContactsBulkEditDialogProps) { + const [field, setField] = useState("company") + const [value, setValue] = useState("") + + const suggestions = useMemo( + () => collectBulkFieldSuggestions(contacts, field), + [contacts, field], + ) + + const mixedValues = useMemo(() => { + const values = new Set( + contacts.map((c) => getContactBulkFieldValue(c, field)).filter(Boolean), + ) + return values.size > 1 + }, [contacts, field]) + + useEffect(() => { + if (!open) { + setField("company") + setValue("") + return + } + if (suggestions.length === 1) { + setValue(suggestions[0]!) + } else { + setValue("") + } + }, [open, field, suggestions]) + + const isNotes = field === "notes" + const canApply = contacts.length > 0 + + function handleApply() { + if (!canApply) return + onApply(field, value) + onOpenChange(false) + } + + return ( + + + + Édition de masse +

+ {contacts.length} contact{contacts.length > 1 ? "s" : ""} sélectionné + {contacts.length > 1 ? "s" : ""} +

+
+ +
+
+ + +
+ +
+ + {isNotes ? ( +