hehe
This commit is contained in:
parent
ae54fa29e4
commit
77f99d8d8a
7
app/contacts/[[...slug]]/page.tsx
Normal file
7
app/contacts/[[...slug]]/page.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { ContactsAppShell } from "@/components/gmail/contacts-page/contacts-app-shell"
|
||||
|
||||
export default function ContactsPage() {
|
||||
return <ContactsAppShell />
|
||||
}
|
||||
13
app/contacts/layout.tsx
Normal file
13
app/contacts/layout.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import type { Metadata } from "next"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Contacts - Ultimail",
|
||||
}
|
||||
|
||||
export default function ContactsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return children
|
||||
}
|
||||
113
components/gmail/contacts-page/add-coordinates-view.tsx
Normal file
113
components/gmail/contacts-page/add-coordinates-view.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
|
||||
export function AddCoordinatesView() {
|
||||
const { getCoordinateSuggestions, updateContact } = useContactsStore()
|
||||
const suggestions = useMemo(() => getCoordinateSuggestions(), [getCoordinateSuggestions])
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set())
|
||||
|
||||
const visible = suggestions.filter((s) => !dismissed.has(s.contact.id))
|
||||
|
||||
function handleAdd(contactId: string, field: string, value: string) {
|
||||
updateContact(contactId, { [field]: value })
|
||||
setDismissed((s) => new Set(s).add(contactId))
|
||||
}
|
||||
|
||||
function handleIgnore(contactId: string) {
|
||||
setDismissed((s) => new Set(s).add(contactId))
|
||||
}
|
||||
|
||||
function handleAddAll() {
|
||||
for (const s of visible) {
|
||||
updateContact(s.contact.id, { [s.suggestedField]: s.suggestedValue })
|
||||
}
|
||||
setDismissed(new Set(suggestions.map((s) => s.contact.id)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-normal text-[#1f1f1f]">
|
||||
Ajouter des coordonnées ({visible.length})
|
||||
</h3>
|
||||
{visible.length > 0 && (
|
||||
<Button
|
||||
onClick={handleAddAll}
|
||||
className="rounded-full bg-[#1a73e8] px-5 text-sm font-medium text-white hover:bg-[#1557b0]"
|
||||
>
|
||||
Ajouter tous les détails
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-[#5f6368]">
|
||||
Aucune suggestion disponible
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{visible.map((suggestion) => {
|
||||
const { contact, suggestedField, suggestedValue } = suggestion
|
||||
const displayName = fullContactDisplayName(contact)
|
||||
const name = displayName || contact.emails[0]?.value || "?"
|
||||
const color = avatarColor(name)
|
||||
const initial = senderInitial(name)
|
||||
|
||||
return (
|
||||
<div key={contact.id} className="rounded-xl border border-gray-200 p-5">
|
||||
<p className="mb-2 text-xs font-medium text-[#5f6368]">Contact à modifier</p>
|
||||
<div className="flex items-start gap-3">
|
||||
{contact.avatarUrl ? (
|
||||
<img src={contact.avatarUrl} alt={name} className="h-10 w-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[#1f1f1f]">{name}</p>
|
||||
{contact.emails[0] && (
|
||||
<p className="truncate text-xs text-[#5f6368]">{contact.emails[0].value}</p>
|
||||
)}
|
||||
{contact.phones[0] && (
|
||||
<p className="truncate text-xs text-[#5f6368]">{contact.phones[0].value} ({contact.phones[0].label})</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 border-t border-gray-100 pt-3">
|
||||
<p className="text-xs font-medium text-[#5f6368]">Détails à ajouter</p>
|
||||
<p className="mt-1 text-sm text-[#1f1f1f]">{suggestedValue}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleIgnore(contact.id)}
|
||||
className="text-sm font-medium text-[#1a73e8] hover:text-[#1557b0]"
|
||||
>
|
||||
Ignorer
|
||||
</button>
|
||||
<Button
|
||||
onClick={() => handleAdd(contact.id, suggestedField, suggestedValue)}
|
||||
className="rounded-full bg-[#1a73e8] px-5 text-sm font-medium text-white hover:bg-[#1557b0]"
|
||||
>
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
components/gmail/contacts-page/bulk-create-dialog.tsx
Normal file
71
components/gmail/contacts-page/bulk-create-dialog.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { parseBulkContactText } from "@/lib/contacts/import-parsers"
|
||||
|
||||
interface BulkCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onOpenImport?: () => void
|
||||
}
|
||||
|
||||
export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreateDialogProps) {
|
||||
const [input, setInput] = useState("")
|
||||
const addContacts = useContactsStore((s) => s.addContacts)
|
||||
|
||||
function handleCreate() {
|
||||
const parsed = parseBulkContactText(input)
|
||||
if (parsed.length === 0) return
|
||||
|
||||
addContacts(parsed)
|
||||
setInput("")
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer plusieurs contacts</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<p className="text-sm text-[#5f6368]">
|
||||
Ajoutez des noms, des adresses e-mail ou les deux
|
||||
</p>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Exemples : Andrea Fisher, weaver.blake98@gmail.com, Elisa Beckett <elisa.beckett@gmail.com>"
|
||||
className="h-24 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-[#5f6368]">
|
||||
Vous avez un fichier CSV ou vCard ?{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-[#1a73e8] hover:underline"
|
||||
onClick={() => { onOpenChange(false); onOpenImport?.() }}
|
||||
>
|
||||
Importez les contacts.
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} className="text-sm font-medium text-[#1a73e8]">
|
||||
Non, ne rien faire
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!input.trim()} className="text-sm font-medium">
|
||||
Créer
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
507
components/gmail/contacts-page/contact-create-page.tsx
Normal file
507
components/gmail/contacts-page/contact-create-page.tsx
Normal file
@ -0,0 +1,507 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useId,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useRef,
|
||||
type InputHTMLAttributes,
|
||||
} from "react"
|
||||
import { useForm, useFieldArray, Controller } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Star,
|
||||
User,
|
||||
Building2,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Cake,
|
||||
FileText,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
X,
|
||||
Check,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
import { useNavStore } from "@/lib/stores/nav-store"
|
||||
|
||||
const FRENCH_MONTHS = [
|
||||
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
|
||||
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre",
|
||||
] as const
|
||||
|
||||
const EMAIL_LABELS = ["Domicile", "Travail", "Autre"] as const
|
||||
const PHONE_LABELS = ["Mobile", "Domicile", "Travail"] as const
|
||||
const ADDRESS_LABELS = ["Domicile", "Travail", "Autre"] as const
|
||||
|
||||
const addressSchema = z.object({
|
||||
street: z.string().optional().default(""),
|
||||
city: z.string().optional().default(""),
|
||||
region: z.string().optional().default(""),
|
||||
postalCode: z.string().optional().default(""),
|
||||
country: z.string().optional().default(""),
|
||||
label: z.string().default("Domicile"),
|
||||
})
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
namePrefix: z.string().optional().default(""),
|
||||
firstName: z.string().optional().default(""),
|
||||
middleName: z.string().optional().default(""),
|
||||
lastName: z.string().optional().default(""),
|
||||
nameSuffix: z.string().optional().default(""),
|
||||
phoneticFirstName: z.string().optional().default(""),
|
||||
phoneticLastName: z.string().optional().default(""),
|
||||
company: z.string().optional().default(""),
|
||||
department: z.string().optional().default(""),
|
||||
jobTitle: z.string().optional().default(""),
|
||||
emails: z.array(z.object({ value: z.string(), label: z.string() })),
|
||||
phones: z.array(z.object({ value: z.string(), label: z.string() })),
|
||||
addresses: z.array(addressSchema),
|
||||
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([]),
|
||||
})
|
||||
|
||||
type ContactFormValues = z.infer<typeof contactFormSchema>
|
||||
|
||||
interface ContactCreatePageProps {
|
||||
mode: "create" | "edit"
|
||||
contactId?: string
|
||||
onBack: () => void
|
||||
onSaved: (id: string) => void
|
||||
}
|
||||
|
||||
export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactCreatePageProps) {
|
||||
const { contacts, addContact, updateContact } = useContactsStore()
|
||||
const labelRows = useNavStore((s) => s.labelRows)
|
||||
const availableLabels = labelRows.filter((r) => r.enabled !== false)
|
||||
const [starred, setStarred] = useState(false)
|
||||
const [nameExpanded, setNameExpanded] = useState(false)
|
||||
const [companyExpanded, setCompanyExpanded] = useState(false)
|
||||
|
||||
const existingContact = mode === "edit" ? contacts.find((c) => c.id === contactId) : null
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isDirty },
|
||||
} = useForm<ContactFormValues>({
|
||||
resolver: zodResolver(contactFormSchema),
|
||||
defaultValues: {
|
||||
namePrefix: "", firstName: "", middleName: "", lastName: "", nameSuffix: "",
|
||||
phoneticFirstName: "", phoneticLastName: "",
|
||||
company: "", department: "", jobTitle: "",
|
||||
emails: [{ value: "", label: "Domicile" }],
|
||||
phones: [{ value: "", label: "Mobile" }],
|
||||
addresses: [],
|
||||
birthday: { day: undefined, month: undefined, year: undefined },
|
||||
notes: "", labels: [],
|
||||
},
|
||||
})
|
||||
|
||||
const { fields: emailFields, append: appendEmail, remove: removeEmail } = useFieldArray({ control, name: "emails" })
|
||||
const { fields: phoneFields, append: appendPhone, remove: removePhone } = useFieldArray({ control, name: "phones" })
|
||||
const { fields: addressFields, append: appendAddress, remove: removeAddress } = useFieldArray({ control, name: "addresses" })
|
||||
|
||||
useEffect(() => {
|
||||
if (existingContact) {
|
||||
const hasExtendedName = !!(existingContact.namePrefix || existingContact.middleName || existingContact.nameSuffix || existingContact.phoneticFirstName || existingContact.phoneticLastName)
|
||||
if (hasExtendedName) setNameExpanded(true)
|
||||
if (existingContact.department) setCompanyExpanded(true)
|
||||
|
||||
reset({
|
||||
namePrefix: existingContact.namePrefix ?? "",
|
||||
firstName: existingContact.firstName,
|
||||
middleName: existingContact.middleName ?? "",
|
||||
lastName: existingContact.lastName,
|
||||
nameSuffix: existingContact.nameSuffix ?? "",
|
||||
phoneticFirstName: existingContact.phoneticFirstName ?? "",
|
||||
phoneticLastName: existingContact.phoneticLastName ?? "",
|
||||
company: existingContact.company ?? "",
|
||||
department: existingContact.department ?? "",
|
||||
jobTitle: existingContact.jobTitle ?? "",
|
||||
emails: existingContact.emails.length ? existingContact.emails : [{ value: "", label: "Domicile" }],
|
||||
phones: existingContact.phones.length ? existingContact.phones : [{ value: "", label: "Mobile" }],
|
||||
addresses: existingContact.addresses ?? [],
|
||||
birthday: existingContact.birthday ?? { day: undefined, month: undefined, year: undefined },
|
||||
notes: existingContact.notes ?? "",
|
||||
labels: existingContact.labels ?? [],
|
||||
})
|
||||
}
|
||||
}, [existingContact, reset])
|
||||
|
||||
const firstName = watch("firstName")
|
||||
const lastName = watch("lastName")
|
||||
const watchedEmails = watch("emails")
|
||||
const currentLabels = watch("labels") ?? []
|
||||
const displayName = `${firstName ?? ""} ${lastName ?? ""}`.trim()
|
||||
|
||||
const canSave = isDirty || (mode === "create" && !!(firstName?.trim() || lastName?.trim() || watchedEmails?.some((e) => e.value?.trim())))
|
||||
|
||||
const toggleLabel = useCallback((labelId: string) => {
|
||||
const next = currentLabels.includes(labelId) ? currentLabels.filter((l) => l !== labelId) : [...currentLabels, labelId]
|
||||
setValue("labels", next, { shouldDirty: true })
|
||||
}, [currentLabels, setValue])
|
||||
|
||||
function onSubmit(data: ContactFormValues) {
|
||||
const payload = {
|
||||
namePrefix: data.namePrefix || undefined,
|
||||
firstName: data.firstName ?? "",
|
||||
middleName: data.middleName || undefined,
|
||||
lastName: data.lastName ?? "",
|
||||
nameSuffix: data.nameSuffix || undefined,
|
||||
phoneticFirstName: data.phoneticFirstName || undefined,
|
||||
phoneticLastName: data.phoneticLastName || undefined,
|
||||
company: data.company || undefined,
|
||||
department: data.department || undefined,
|
||||
jobTitle: data.jobTitle || undefined,
|
||||
emails: data.emails.filter((e) => e.value),
|
||||
phones: data.phones.filter((p) => p.value),
|
||||
addresses: data.addresses.filter((a) => a.street || a.city || a.region || a.postalCode || a.country),
|
||||
birthday: data.birthday?.day || data.birthday?.month || data.birthday?.year ? data.birthday : undefined,
|
||||
notes: data.notes || undefined,
|
||||
labels: data.labels?.length ? data.labels : undefined,
|
||||
}
|
||||
|
||||
if (mode === "create") {
|
||||
const id = addContact(payload)
|
||||
onSaved(id)
|
||||
} else if (contactId) {
|
||||
updateContact(contactId, payload)
|
||||
onSaved(contactId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mx-auto max-w-2xl px-6 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<Button type="button" variant="ghost" size="icon" className="h-10 w-10 rounded-full text-[#5f6368]" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="icon" className="h-10 w-10 rounded-full" onClick={() => setStarred((s) => !s)}>
|
||||
<Star className={`h-5 w-5 ${starred ? "fill-yellow-400 text-yellow-400" : "text-[#5f6368]"}`} />
|
||||
</Button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSave}
|
||||
className="rounded-full bg-[#f1f3f4] px-6 py-2.5 text-sm font-medium text-[#3c4043] transition-colors hover:bg-[#e8eaed] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="mb-6 flex flex-col items-center">
|
||||
{displayName ? (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="flex h-28 w-28 items-center justify-center rounded-full text-4xl font-medium text-white"
|
||||
style={{ backgroundColor: avatarColor(displayName) }}
|
||||
>
|
||||
{senderInitial(displayName)}
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 flex h-8 w-8 items-center justify-center rounded-full bg-[#1a73e8] text-white shadow">
|
||||
<Plus className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-[#e8eaed]">
|
||||
<User className="h-12 w-12 text-[#9aa0a6]" />
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 flex h-8 w-8 items-center justify-center rounded-full bg-[#1a73e8] text-white shadow">
|
||||
<Plus className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-center gap-1.5">
|
||||
{currentLabels.map((labelId) => {
|
||||
const row = labelRows.find((r) => r.id === labelId)
|
||||
return (
|
||||
<span key={labelId} className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs text-gray-700">
|
||||
{row && (
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
|
||||
)}
|
||||
{row?.label ?? labelId}
|
||||
<button type="button" onClick={() => toggleLabel(labelId)} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className="inline-flex items-center gap-1 rounded-full border border-gray-300 px-2.5 py-0.5 text-xs text-gray-600 hover:bg-gray-50">
|
||||
<Plus className="h-3 w-3" /> Libellé
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 p-1" align="center">
|
||||
<p className="px-2 py-1.5 text-xs font-medium text-gray-500">Libellés</p>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{availableLabels.map((row) => {
|
||||
const active = currentLabels.includes(row.id)
|
||||
return (
|
||||
<button key={row.id} type="button" onClick={() => toggleLabel(row.id)} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-gray-100">
|
||||
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${row.color}`} />
|
||||
<span className="flex-1 truncate">{row.label}</span>
|
||||
{active && <Check className="h-3.5 w-3.5 text-blue-600" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Name section */}
|
||||
<FormSection icon={<User className="h-5 w-5 text-[#5f6368]" />}>
|
||||
{nameExpanded && <FloatingInput label="Titre (M., Mme...)" {...register("namePrefix")} />}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1"><FloatingInput label="Prénom" {...register("firstName")} /></div>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 rounded-full text-gray-400" onClick={() => setNameExpanded((e) => !e)}>
|
||||
{nameExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{nameExpanded && <FloatingInput label="Deuxième prénom" {...register("middleName")} />}
|
||||
<FloatingInput label="Nom" {...register("lastName")} />
|
||||
{nameExpanded && (
|
||||
<>
|
||||
<FloatingInput label="Suffixe (Jr., Sr...)" {...register("nameSuffix")} />
|
||||
<FloatingInput label="Prénom phonétique" {...register("phoneticFirstName")} />
|
||||
<FloatingInput label="Nom phonétique" {...register("phoneticLastName")} />
|
||||
</>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
{/* Company section */}
|
||||
<FormSection icon={<Building2 className="h-5 w-5 text-[#5f6368]" />}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1"><FloatingInput label="Entreprise" {...register("company")} /></div>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 rounded-full text-gray-400" onClick={() => setCompanyExpanded((e) => !e)}>
|
||||
{companyExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{companyExpanded && <FloatingInput label="Service" {...register("department")} />}
|
||||
<FloatingInput label="Fonction" {...register("jobTitle")} />
|
||||
</FormSection>
|
||||
|
||||
{/* Email section */}
|
||||
<FormSection icon={<Mail className="h-5 w-5 text-[#5f6368]" />}>
|
||||
{emailFields.map((field, index) => (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1"><FloatingInput label="E-mail" type="email" {...register(`emails.${index}.value`)} /></div>
|
||||
{emailFields.length > 1 && (
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 rounded-full text-gray-400" onClick={() => removeEmail(index)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Controller control={control} name={`emails.${index}.label`} render={({ field: f }) => (
|
||||
<CompactSelect value={f.value} onValueChange={f.onChange} options={EMAIL_LABELS.map((l) => ({ value: l, label: l }))} />
|
||||
)} />
|
||||
</div>
|
||||
))}
|
||||
<AddButton onClick={() => appendEmail({ value: "", label: "Domicile" })}>Ajouter une adresse e-mail</AddButton>
|
||||
</FormSection>
|
||||
|
||||
{/* Phone section */}
|
||||
<FormSection icon={<Phone className="h-5 w-5 text-[#5f6368]" />}>
|
||||
{phoneFields.map((field, index) => (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded text-sm">🇫🇷</span>
|
||||
<div className="flex-1"><FloatingInput label="Téléphone" type="tel" {...register(`phones.${index}.value`)} /></div>
|
||||
{phoneFields.length > 1 && (
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 rounded-full text-gray-400" onClick={() => removePhone(index)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Controller control={control} name={`phones.${index}.label`} render={({ field: f }) => (
|
||||
<CompactSelect value={f.value} onValueChange={f.onChange} options={PHONE_LABELS.map((l) => ({ value: l, label: l }))} />
|
||||
)} />
|
||||
</div>
|
||||
))}
|
||||
<AddButton onClick={() => appendPhone({ value: "", label: "Mobile" })}>Ajouter un numéro de téléphone</AddButton>
|
||||
</FormSection>
|
||||
|
||||
{/* Address section */}
|
||||
<FormSection icon={<MapPin className="h-5 w-5 text-[#5f6368]" />}>
|
||||
{addressFields.map((field, index) => (
|
||||
<div key={field.id} className="space-y-2 rounded-lg border border-gray-200 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Controller control={control} name={`addresses.${index}.label`} render={({ field: f }) => (
|
||||
<CompactSelect value={f.value} onValueChange={f.onChange} options={ADDRESS_LABELS.map((l) => ({ value: l, label: l }))} />
|
||||
)} />
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 rounded-full text-gray-400" onClick={() => removeAddress(index)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<FloatingInput label="Rue" {...register(`addresses.${index}.street`)} />
|
||||
<div className="flex gap-2">
|
||||
<div className="w-24"><FloatingInput label="Code postal" {...register(`addresses.${index}.postalCode`)} /></div>
|
||||
<div className="flex-1"><FloatingInput label="Ville" {...register(`addresses.${index}.city`)} /></div>
|
||||
</div>
|
||||
<FloatingInput label="Région / Province" {...register(`addresses.${index}.region`)} />
|
||||
<FloatingInput label="Pays" {...register(`addresses.${index}.country`)} />
|
||||
</div>
|
||||
))}
|
||||
<AddButton onClick={() => appendAddress({ street: "", city: "", region: "", postalCode: "", country: "", label: "Domicile" })}>
|
||||
Ajouter une adresse
|
||||
</AddButton>
|
||||
</FormSection>
|
||||
|
||||
{/* Birthday section */}
|
||||
<FormSection icon={<Cake className="h-5 w-5 text-[#5f6368]" />}>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<div className="w-[72px]"><FloatingInput label="Jour" type="number" min={1} max={31} {...register("birthday.day", { valueAsNumber: true })} /></div>
|
||||
<div className="flex-1">
|
||||
<Controller control={control} name="birthday.month" render={({ field: f }) => (
|
||||
<CompactSelect value={f.value ? String(f.value) : ""} onValueChange={(v) => f.onChange(v ? Number(v) : undefined)} options={FRENCH_MONTHS.map((name, i) => ({ value: String(i + 1), label: name }))} placeholder="Mois" />
|
||||
)} />
|
||||
</div>
|
||||
<div className="w-24"><FloatingInput label="Année" type="number" min={1900} max={2100} {...register("birthday.year", { valueAsNumber: true })} /></div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Notes section */}
|
||||
<FormSection icon={<FileText className="h-5 w-5 text-[#5f6368]" />}>
|
||||
<FloatingTextarea label="Notes" {...register("notes")} />
|
||||
</FormSection>
|
||||
|
||||
<div className="h-12" />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function FormSection({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-4 py-3">
|
||||
<div className="flex w-6 shrink-0 pt-2">{icon}</div>
|
||||
<div className="flex-1 space-y-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className="flex items-center gap-2 py-1 text-sm text-[#1a73e8] hover:text-[#1557b0]">
|
||||
<Plus className="h-4 w-4" />{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface FloatingInputProps extends InputHTMLAttributes<HTMLInputElement> { label: string }
|
||||
|
||||
const FloatingInput = forwardRef<HTMLInputElement, FloatingInputProps>(
|
||||
function FloatingInput({ label, className, defaultValue, ...props }, ref) {
|
||||
const id = useId()
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [filled, setFilled] = useState(() => !!defaultValue)
|
||||
const innerRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
useEffect(() => { if (innerRef.current && innerRef.current.value) setFilled(true) })
|
||||
|
||||
const setRefs = useCallback((node: HTMLInputElement | null) => {
|
||||
innerRef.current = node
|
||||
if (typeof ref === "function") ref(node); else if (ref) ref.current = node
|
||||
if (node && node.value) setFilled(true)
|
||||
}, [ref])
|
||||
|
||||
const floated = focused || filled
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input ref={setRefs} id={id} {...props} defaultValue={defaultValue}
|
||||
className={`peer h-[42px] w-full rounded border bg-white px-3 pt-4 pb-1 text-sm outline-none transition-colors ${focused ? "border-blue-500 ring-1 ring-blue-500" : "border-gray-300"} ${className ?? ""}`}
|
||||
onFocus={(e) => { setFocused(true); props.onFocus?.(e) }}
|
||||
onBlur={(e) => { setFocused(false); setFilled(!!e.target.value); props.onBlur?.(e) }}
|
||||
onChange={(e) => { setFilled(!!e.target.value); props.onChange?.(e) }}
|
||||
/>
|
||||
<label htmlFor={id} className={`pointer-events-none absolute left-3 bg-white transition-all duration-150 ${floated ? "top-0.5 px-0.5 text-[10px] leading-tight" : "top-[11px] text-sm"} ${focused ? "text-blue-600" : "text-gray-500"}`}>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
interface FloatingTextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { label: string }
|
||||
|
||||
const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
|
||||
function FloatingTextarea({ label, className, ...props }, ref) {
|
||||
const id = useId()
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [filled, setFilled] = useState(false)
|
||||
const innerRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
useEffect(() => { if (innerRef.current && innerRef.current.value) setFilled(true) })
|
||||
|
||||
const setRefs = useCallback((node: HTMLTextAreaElement | null) => {
|
||||
innerRef.current = node
|
||||
if (typeof ref === "function") ref(node); else if (ref) ref.current = node
|
||||
if (node && node.value) setFilled(true)
|
||||
}, [ref])
|
||||
|
||||
const floated = focused || filled
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<textarea ref={setRefs} id={id} rows={3} {...props}
|
||||
className={`peer w-full rounded border bg-white px-3 pt-5 pb-2 text-sm outline-none transition-colors resize-none ${focused ? "border-blue-500 ring-1 ring-blue-500" : "border-gray-300"} ${className ?? ""}`}
|
||||
onFocus={(e) => { setFocused(true); props.onFocus?.(e) }}
|
||||
onBlur={(e) => { setFocused(false); setFilled(!!e.target.value); props.onBlur?.(e) }}
|
||||
onChange={(e) => { setFilled(!!e.target.value); props.onChange?.(e) }}
|
||||
/>
|
||||
<label htmlFor={id} className={`pointer-events-none absolute left-3 bg-white transition-all duration-150 ${floated ? "top-1 px-0.5 text-[10px] leading-tight" : "top-2.5 text-sm"} ${focused ? "text-blue-600" : "text-gray-500"}`}>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
function CompactSelect({ value, onValueChange, options, placeholder }: { value: string; onValueChange: (v: string) => void; options: { value: string; label: string }[]; placeholder?: string }) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="!h-[42px] !min-h-[42px] w-full rounded border border-gray-300 bg-white px-3 py-0 text-sm shadow-none data-[size=default]:!h-[42px] focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
|
||||
<SelectValue placeholder={placeholder ?? "Choisir..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
248
components/gmail/contacts-page/contact-detail-page.tsx
Normal file
248
components/gmail/contacts-page/contact-detail-page.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
Pencil,
|
||||
Star,
|
||||
Trash2,
|
||||
Mail,
|
||||
Phone,
|
||||
Building2,
|
||||
MapPin,
|
||||
Cake,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
Video,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
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"
|
||||
|
||||
const FRENCH_MONTHS = [
|
||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||
]
|
||||
|
||||
function formatBirthday(b: { day?: number; month?: number; year?: number }): string {
|
||||
const parts: string[] = []
|
||||
if (b.day) parts.push(String(b.day))
|
||||
if (b.month) parts.push(FRENCH_MONTHS[b.month - 1] ?? "")
|
||||
if (b.year) parts.push(String(b.year))
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
interface ContactDetailPageProps {
|
||||
contactId: string
|
||||
onBack: () => void
|
||||
onEdit: (id: string) => void
|
||||
}
|
||||
|
||||
export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPageProps) {
|
||||
const { contacts, softDeleteContact } = useContactsStore()
|
||||
const labelRows = useNavStore((s) => s.labelRows)
|
||||
const contact = contacts.find((c) => c.id === contactId)
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-500">
|
||||
Contact introuvable
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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() {
|
||||
softDeleteContact(contactId, "Supprimé manuellement")
|
||||
onBack()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-8">
|
||||
{/* Top actions */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full text-[#5f6368]"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 rounded-full text-[#5f6368]">
|
||||
<Star className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full text-[#5f6368]"
|
||||
onClick={() => downloadContactVCard(contact)}
|
||||
aria-label="Télécharger la fiche contact"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full text-[#5f6368]"
|
||||
onClick={() => onEdit(contactId)}
|
||||
>
|
||||
<Pencil className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-full text-[#5f6368]"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avatar + name */}
|
||||
<div className="flex items-center gap-6 pb-6">
|
||||
{contact.avatarUrl ? (
|
||||
<img src={contact.avatarUrl} alt={name} className="h-24 w-24 rounded-full object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className="flex h-24 w-24 items-center justify-center rounded-full text-3xl font-medium text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-3xl font-normal text-[#1f1f1f]">{name}</h1>
|
||||
{contact.company && (
|
||||
<p className="mt-1 text-base text-[#5f6368]">
|
||||
{contact.jobTitle ? `${contact.jobTitle} — ` : ""}
|
||||
{contact.company}
|
||||
</p>
|
||||
)}
|
||||
{contact.labels && contact.labels.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{contact.labels.map((labelId) => {
|
||||
const row = labelRows.find((r) => r.id === labelId)
|
||||
return row ? (
|
||||
<span
|
||||
key={labelId}
|
||||
className="inline-flex items-center gap-1 rounded border border-gray-300 px-2 py-0.5 text-xs text-[#3c4043]"
|
||||
>
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
|
||||
{row.label}
|
||||
</span>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
{primaryEmail && (
|
||||
<div className="flex items-center gap-2 border-t border-gray-200 py-4">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-full bg-[#d3e3fd] px-5 text-sm font-medium text-[#001d35] transition-colors hover:bg-[#c4d9fc]"
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
Envoyer un e-mail
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-[#dadce0] text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full border border-[#dadce0] text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<Video className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-1 border-t border-gray-200 pt-4">
|
||||
{contact.emails.length > 0 && (
|
||||
<DetailRow icon={<Mail className="h-5 w-5 text-[#5f6368]" />}>
|
||||
{contact.emails.map((e, i) => (
|
||||
<div key={i}>
|
||||
<p className="text-sm text-[#1a73e8]">{e.value}</p>
|
||||
<p className="text-xs text-[#5f6368]">{e.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
{contact.phones.length > 0 && (
|
||||
<DetailRow icon={<Phone className="h-5 w-5 text-[#5f6368]" />}>
|
||||
{contact.phones.map((p, i) => (
|
||||
<div key={i}>
|
||||
<p className="text-sm text-[#1a73e8]">{p.value}</p>
|
||||
<p className="text-xs text-[#5f6368]">{p.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
{contact.company && (
|
||||
<DetailRow icon={<Building2 className="h-5 w-5 text-[#5f6368]" />}>
|
||||
<div>
|
||||
<p className="text-sm text-[#1f1f1f]">{contact.company}</p>
|
||||
{contact.department && <p className="text-xs text-[#5f6368]">{contact.department}</p>}
|
||||
{contact.jobTitle && <p className="text-xs text-[#5f6368]">{contact.jobTitle}</p>}
|
||||
</div>
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
{contact.addresses && contact.addresses.length > 0 && (
|
||||
<DetailRow icon={<MapPin className="h-5 w-5 text-[#5f6368]" />}>
|
||||
{contact.addresses.map((addr, i) => (
|
||||
<div key={i}>
|
||||
<p className="text-sm text-[#1f1f1f]">
|
||||
{[addr.street, [addr.postalCode, addr.city].filter(Boolean).join(" "), addr.region, addr.country]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</p>
|
||||
<p className="text-xs text-[#5f6368]">{addr.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
{contact.birthday && (contact.birthday.day || contact.birthday.month) && (
|
||||
<DetailRow icon={<Cake className="h-5 w-5 text-[#5f6368]" />}>
|
||||
<p className="text-sm text-[#1f1f1f]">{formatBirthday(contact.birthday)}</p>
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
{contact.notes && (
|
||||
<DetailRow icon={<FileText className="h-5 w-5 text-[#5f6368]" />}>
|
||||
<p className="whitespace-pre-wrap text-sm text-[#1f1f1f]">{contact.notes}</p>
|
||||
</DetailRow>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-4 py-3">
|
||||
<div className="flex w-6 shrink-0 pt-0.5">{icon}</div>
|
||||
<div className="flex-1 space-y-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
components/gmail/contacts-page/contacts-app-shell.tsx
Normal file
124
components/gmail/contacts-page/contacts-app-shell.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ContactsSidebar } from "./contacts-sidebar"
|
||||
import { ContactsHeader } from "./contacts-header"
|
||||
import { ContactsTable } from "./contacts-table"
|
||||
import { ContactDetailPage } from "./contact-detail-page"
|
||||
import { ContactCreatePage } from "./contact-create-page"
|
||||
import { MergeDuplicatesView } from "./merge-duplicates-view"
|
||||
import { TrashView } from "./trash-view"
|
||||
import { BulkCreateDialog } from "./bulk-create-dialog"
|
||||
import { ImportDialog } from "./import-dialog"
|
||||
|
||||
export type ContactsPageView =
|
||||
| "contacts"
|
||||
| "frequent"
|
||||
| "other"
|
||||
| "merge"
|
||||
| "import"
|
||||
| "trash"
|
||||
| "detail"
|
||||
| "create"
|
||||
| "edit"
|
||||
| "label"
|
||||
|
||||
export function ContactsAppShell() {
|
||||
const [currentView, setCurrentView] = useState<ContactsPageView>("contacts")
|
||||
const [activeContactId, setActiveContactId] = useState<string | null>(null)
|
||||
const [activeLabelId, setActiveLabelId] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [bulkCreateOpen, setBulkCreateOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setSearchQuery("")
|
||||
}, [currentView, activeLabelId])
|
||||
|
||||
function openContact(id: string) {
|
||||
setActiveContactId(id)
|
||||
setCurrentView("detail")
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setActiveContactId(null)
|
||||
setCurrentView("create")
|
||||
}
|
||||
|
||||
function openEdit(id: string) {
|
||||
setActiveContactId(id)
|
||||
setCurrentView("edit")
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
setActiveContactId(null)
|
||||
setCurrentView("contacts")
|
||||
}
|
||||
|
||||
function handleNavigate(view: ContactsPageView) {
|
||||
if (view === "import") {
|
||||
setImportOpen(true)
|
||||
return
|
||||
}
|
||||
setCurrentView(view)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh max-h-dvh overflow-hidden bg-white">
|
||||
<ContactsSidebar
|
||||
currentView={currentView}
|
||||
activeLabelId={activeLabelId}
|
||||
onNavigate={handleNavigate}
|
||||
onCreateContact={openCreate}
|
||||
onBulkCreate={() => setBulkCreateOpen(true)}
|
||||
onSelectLabel={(id) => { setActiveLabelId(id); setCurrentView("label") }}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<ContactsHeader
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto">
|
||||
{(currentView === "contacts" ||
|
||||
currentView === "frequent" ||
|
||||
currentView === "other" ||
|
||||
currentView === "label") && (
|
||||
<ContactsTable
|
||||
view={currentView}
|
||||
searchQuery={searchQuery}
|
||||
activeLabelId={activeLabelId}
|
||||
onOpenContact={openContact}
|
||||
/>
|
||||
)}
|
||||
{currentView === "detail" && activeContactId && (
|
||||
<ContactDetailPage
|
||||
contactId={activeContactId}
|
||||
onBack={goBack}
|
||||
onEdit={openEdit}
|
||||
/>
|
||||
)}
|
||||
{currentView === "create" && (
|
||||
<ContactCreatePage
|
||||
mode="create"
|
||||
onBack={goBack}
|
||||
onSaved={(id) => openContact(id)}
|
||||
/>
|
||||
)}
|
||||
{currentView === "edit" && activeContactId && (
|
||||
<ContactCreatePage
|
||||
mode="edit"
|
||||
contactId={activeContactId}
|
||||
onBack={() => openContact(activeContactId)}
|
||||
onSaved={(id) => openContact(id)}
|
||||
/>
|
||||
)}
|
||||
{currentView === "merge" && <MergeDuplicatesView />}
|
||||
{currentView === "trash" && <TrashView />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<ImportDialog open={importOpen} onOpenChange={setImportOpen} />
|
||||
<BulkCreateDialog open={bulkCreateOpen} onOpenChange={setBulkCreateOpen} onOpenImport={() => setImportOpen(true)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
components/gmail/contacts-page/contacts-header.tsx
Normal file
40
components/gmail/contacts-page/contacts-header.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import { Search, X } from "lucide-react"
|
||||
import { HeaderAccountActions } from "@/components/gmail/header-account-actions"
|
||||
|
||||
interface ContactsHeaderProps {
|
||||
searchQuery: string
|
||||
onSearchChange: (q: string) => void
|
||||
}
|
||||
|
||||
export function ContactsHeader({ searchQuery, onSearchChange }: ContactsHeaderProps) {
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-4 border-b border-gray-200 px-6">
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<div className="flex h-12 w-full max-w-[720px] items-center gap-3 rounded-full bg-[#edf2fc] px-4 transition-colors focus-within:bg-white focus-within:shadow-md focus-within:ring-1 focus-within:ring-gray-200">
|
||||
<Search className="h-5 w-5 shrink-0 text-[#5f6368]" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Rechercher"
|
||||
className="flex-1 bg-transparent text-sm text-[#1f1f1f] outline-none placeholder:text-[#5f6368]"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
className="rounded-full p-1 hover:bg-gray-100"
|
||||
aria-label="Effacer la recherche"
|
||||
>
|
||||
<X className="h-4 w-4 text-[#5f6368]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeaderAccountActions className="pl-4" />
|
||||
</header>
|
||||
)
|
||||
}
|
||||
233
components/gmail/contacts-page/contacts-sidebar.tsx
Normal file
233
components/gmail/contacts-page/contacts-sidebar.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Users,
|
||||
Clock,
|
||||
UserPlus,
|
||||
Merge,
|
||||
Upload,
|
||||
Trash2,
|
||||
Plus,
|
||||
Tag,
|
||||
Menu,
|
||||
ChevronDown,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { useNavStore } from "@/lib/stores/nav-store"
|
||||
import type { ContactsPageView } from "./contacts-app-shell"
|
||||
|
||||
interface ContactsSidebarProps {
|
||||
currentView: ContactsPageView
|
||||
activeLabelId?: string | null
|
||||
onNavigate: (view: ContactsPageView) => void
|
||||
onCreateContact: () => void
|
||||
onBulkCreate?: () => void
|
||||
onSelectLabel?: (id: string) => void
|
||||
}
|
||||
|
||||
export function ContactsSidebar({
|
||||
currentView,
|
||||
activeLabelId,
|
||||
onNavigate,
|
||||
onCreateContact,
|
||||
onBulkCreate,
|
||||
onSelectLabel,
|
||||
}: ContactsSidebarProps) {
|
||||
const contacts = useContactsStore((s) => s.contacts)
|
||||
const mergeSuggestionCount = useContactsStore((s) => s.getMergeSuggestions().length)
|
||||
const labelRows = useNavStore((s) => s.labelRows)
|
||||
const addLabelRowFromSidebar = useNavStore((s) => s.addLabelRowFromSidebar)
|
||||
const [labelInput, setLabelInput] = useState("")
|
||||
const [showLabelInput, setShowLabelInput] = useState(false)
|
||||
|
||||
const availableLabels = labelRows.filter((r) => r.enabled !== false)
|
||||
|
||||
function handleAddLabel() {
|
||||
const trimmed = labelInput.trim()
|
||||
if (trimmed) {
|
||||
addLabelRowFromSidebar(trimmed)
|
||||
setLabelInput("")
|
||||
setShowLabelInput(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-60 shrink-0 flex-col border-r border-gray-200 bg-white">
|
||||
{/* Logo + hamburger */}
|
||||
<div className="flex h-16 items-center gap-2 px-4">
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 rounded-full text-gray-600">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-6 w-6 text-[#5f6368]" />
|
||||
<span className="text-[22px] font-normal text-[#5f6368]">Contacts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create button */}
|
||||
<div className="px-3 pb-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-14 w-full items-center gap-3 rounded-2xl bg-white px-4 shadow-md ring-1 ring-gray-200 transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<Plus className="h-5 w-5 text-[#1a73e8]" />
|
||||
<span className="flex-1 text-left text-sm font-medium text-[#3c4043]">Créer un contact</span>
|
||||
<ChevronDown className="h-4 w-4 text-[#5f6368]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuItem onClick={onCreateContact}>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Créer un contact
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onBulkCreate}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Créer plusieurs contacts
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<nav className="flex-1 overflow-y-auto px-2">
|
||||
<NavItem
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
label="Contacts"
|
||||
count={contacts.length}
|
||||
active={currentView === "contacts"}
|
||||
onClick={() => onNavigate("contacts")}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Clock className="h-5 w-5" />}
|
||||
label="Fréquents"
|
||||
active={currentView === "frequent"}
|
||||
onClick={() => onNavigate("frequent")}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<UserPlus className="h-5 w-5" />}
|
||||
label="Autres contacts"
|
||||
active={currentView === "other"}
|
||||
onClick={() => onNavigate("other")}
|
||||
/>
|
||||
|
||||
<div className="my-2 border-t border-gray-200" />
|
||||
|
||||
<p className="px-3 py-2 text-xs font-medium text-[#5f6368]">Corriger et gérer</p>
|
||||
|
||||
<NavItem
|
||||
icon={<Merge className="h-5 w-5" />}
|
||||
label="Fusionner et corriger"
|
||||
badge={mergeSuggestionCount > 0 ? mergeSuggestionCount : undefined}
|
||||
active={currentView === "merge"}
|
||||
onClick={() => onNavigate("merge")}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Upload className="h-5 w-5" />}
|
||||
label="Importer"
|
||||
active={currentView === "import"}
|
||||
onClick={() => onNavigate("import")}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Trash2 className="h-5 w-5" />}
|
||||
label="Corbeille"
|
||||
active={currentView === "trash"}
|
||||
onClick={() => onNavigate("trash")}
|
||||
/>
|
||||
|
||||
<div className="my-2 border-t border-gray-200" />
|
||||
|
||||
{/* Labels section */}
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<p className="text-xs font-medium text-[#5f6368]">Libellés</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLabelInput(true)}
|
||||
className="rounded-full p-1 text-[#5f6368] hover:bg-gray-100"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showLabelInput && (
|
||||
<div className="flex items-center gap-1 px-3 pb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={labelInput}
|
||||
onChange={(e) => setLabelInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAddLabel()}
|
||||
placeholder="Nom du libellé"
|
||||
className="flex-1 rounded border border-gray-300 px-2 py-1 text-sm outline-none focus:border-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleAddLabel}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableLabels.map((label) => {
|
||||
const count = contacts.filter((c) => c.labels?.includes(label.id)).length
|
||||
return (
|
||||
<NavItem
|
||||
key={label.id}
|
||||
icon={<Tag className="h-5 w-5" />}
|
||||
label={label.label}
|
||||
count={count}
|
||||
active={currentView === "label" && activeLabelId === label.id}
|
||||
onClick={() => onSelectLabel?.(label.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
badge,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
count?: number
|
||||
badge?: number
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-3 rounded-full px-3 py-2 text-sm transition-colors ${
|
||||
active
|
||||
? "bg-[#c2e7ff] font-medium text-[#001d35]"
|
||||
: "text-[#1f1f1f] hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<span className={active ? "text-[#001d35]" : "text-[#444746]"}>{icon}</span>
|
||||
<span className="flex-1 truncate text-left">{label}</span>
|
||||
{badge !== undefined && (
|
||||
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-[#ea4335] px-1.5 text-[11px] font-medium text-white">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{count !== undefined && (
|
||||
<span className="text-xs text-[#5f6368]">{count}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
356
components/gmail/contacts-page/contacts-table.tsx
Normal file
356
components/gmail/contacts-page/contacts-table.tsx
Normal file
@ -0,0 +1,356 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Printer, Download, MoreVertical, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { useNavStore } from "@/lib/stores/nav-store"
|
||||
import { searchContacts } from "@/lib/contacts/fuzzy-search"
|
||||
import { printContacts } from "@/lib/contacts/print-contacts"
|
||||
import { downloadContactsCsv, downloadContactsVCard } from "@/lib/contacts/export-contacts"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
import type { FullContact } from "@/lib/contacts/types"
|
||||
import type { ContactsPageView } from "./contacts-app-shell"
|
||||
|
||||
const TABLE_GRID =
|
||||
"grid grid-cols-[40px_minmax(0,2fr)_minmax(0,2fr)_minmax(0,1.5fr)_minmax(0,1.5fr)_minmax(0,1fr)] gap-2"
|
||||
|
||||
interface ContactsTableProps {
|
||||
view: ContactsPageView
|
||||
searchQuery: string
|
||||
activeLabelId?: string | null
|
||||
onOpenContact: (id: string) => void
|
||||
}
|
||||
|
||||
export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact }: ContactsTableProps) {
|
||||
const contacts = useContactsStore((s) => s.contacts)
|
||||
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
let list = contacts
|
||||
|
||||
if (view === "frequent") {
|
||||
list = [...contacts]
|
||||
.filter((c) => (c.interactionCount ?? 0) > 0)
|
||||
.sort((a, b) => (b.interactionCount ?? 0) - (a.interactionCount ?? 0))
|
||||
} else if (view === "other") {
|
||||
list = contacts.filter((c) => c.isOtherContact === true)
|
||||
} else if (view === "label" && activeLabelId) {
|
||||
list = contacts.filter((c) => c.labels?.includes(activeLabelId))
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
list = searchContacts(list, searchQuery)
|
||||
} else if (view !== "frequent") {
|
||||
list = [...list].sort((a, b) => {
|
||||
const nameA = fullContactDisplayName(a) || a.emails[0]?.value || ""
|
||||
const nameB = fullContactDisplayName(b) || b.emails[0]?.value || ""
|
||||
return nameA.localeCompare(nameB, "fr")
|
||||
})
|
||||
}
|
||||
|
||||
return list
|
||||
}, [contacts, view, searchQuery, activeLabelId])
|
||||
|
||||
const filteredIds = useMemo(
|
||||
() => new Set(filteredContacts.map((c) => c.id)),
|
||||
[filteredContacts]
|
||||
)
|
||||
|
||||
const selectedContacts = useMemo(
|
||||
() => filteredContacts.filter((c) => selectedIds.has(c.id)),
|
||||
[filteredContacts, selectedIds]
|
||||
)
|
||||
|
||||
const selectionCount = selectedContacts.length
|
||||
const allFilteredSelected =
|
||||
filteredContacts.length > 0 &&
|
||||
filteredContacts.every((c) => selectedIds.has(c.id))
|
||||
const someFilteredSelected =
|
||||
filteredContacts.some((c) => selectedIds.has(c.id)) && !allFilteredSelected
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [view, activeLabelId])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set([...prev].filter((id) => filteredIds.has(id)))
|
||||
return next.size === prev.size ? prev : next
|
||||
})
|
||||
}, [filteredIds])
|
||||
|
||||
const labelRows = useNavStore((s) => s.labelRows)
|
||||
const activeLabelName = activeLabelId
|
||||
? labelRows.find((l) => l.id === activeLabelId)?.label
|
||||
: null
|
||||
|
||||
const viewTitle = view === "frequent"
|
||||
? "Contacts fréquents"
|
||||
: view === "other"
|
||||
? "Autres contacts"
|
||||
: view === "label" && activeLabelName
|
||||
? activeLabelName
|
||||
: `Contacts (${contacts.length})`
|
||||
|
||||
function toggleContact(id: string, checked: boolean) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleSelectAll(checked: boolean) {
|
||||
if (checked) {
|
||||
setSelectedIds(new Set(filteredContacts.map((c) => c.id)))
|
||||
} else {
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteSelected() {
|
||||
if (selectionCount === 0) return
|
||||
for (const contact of selectedContacts) {
|
||||
softDeleteContact(contact.id, "Supprimé manuellement")
|
||||
}
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
function handleExportVcf() {
|
||||
if (selectionCount === 0) return
|
||||
const filename =
|
||||
selectionCount === 1
|
||||
? `${sanitizeExportName(selectedContacts[0])}.vcf`
|
||||
: "contacts.vcf"
|
||||
downloadContactsVCard(selectedContacts, filename)
|
||||
}
|
||||
|
||||
function handleExportCsv() {
|
||||
if (selectionCount === 0) return
|
||||
const filename =
|
||||
selectionCount === 1
|
||||
? `${sanitizeExportName(selectedContacts[0])}.csv`
|
||||
: "contacts.csv"
|
||||
downloadContactsCsv(selectedContacts, filename)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 py-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-normal text-[#1f1f1f]">{viewTitle}</h1>
|
||||
{selectionCount > 0 && (
|
||||
<p className="mt-0.5 text-sm text-[#5f6368]">
|
||||
{selectionCount} sélectionné{selectionCount > 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full text-[#5f6368]"
|
||||
onClick={() => printContacts(filteredContacts, viewTitle)}
|
||||
aria-label="Imprimer"
|
||||
>
|
||||
<Printer className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full text-[#5f6368] disabled:opacity-40"
|
||||
disabled={selectionCount === 0}
|
||||
aria-label="Exporter la sélection"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem onClick={handleExportVcf}>
|
||||
Exporter au format vCard (.vcf)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleExportCsv}>
|
||||
Exporter au format CSV (.csv)
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full text-[#5f6368] disabled:opacity-40"
|
||||
disabled={selectionCount === 0}
|
||||
onClick={handleDeleteSelected}
|
||||
aria-label="Supprimer la sélection"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full text-[#5f6368]"
|
||||
aria-label="Plus d'actions"
|
||||
>
|
||||
<MoreVertical className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={() => toggleSelectAll(true)}
|
||||
disabled={filteredContacts.length === 0}
|
||||
>
|
||||
Tout sélectionner
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
disabled={selectionCount === 0}
|
||||
>
|
||||
Désélectionner tout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${TABLE_GRID} border-b border-gray-200 py-2 text-xs font-medium text-[#5f6368]`}
|
||||
>
|
||||
<span className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={allFilteredSelected ? true : someFilteredSelected ? "indeterminate" : false}
|
||||
onCheckedChange={(checked) => toggleSelectAll(checked === true)}
|
||||
aria-label="Tout sélectionner"
|
||||
/>
|
||||
</span>
|
||||
<span>Nom</span>
|
||||
<span>E-mail</span>
|
||||
<span>Numéro de téléphone</span>
|
||||
<span>Fonction et entreprise</span>
|
||||
<span>Libellés</span>
|
||||
</div>
|
||||
|
||||
{filteredContacts.map((contact) => (
|
||||
<ContactTableRow
|
||||
key={contact.id}
|
||||
contact={contact}
|
||||
selected={selectedIds.has(contact.id)}
|
||||
onToggleSelect={(checked) => toggleContact(contact.id, checked)}
|
||||
onOpen={() => onOpenContact(contact.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredContacts.length === 0 && (
|
||||
<div className="py-12 text-center text-sm text-[#5f6368]">
|
||||
Aucun contact trouvé
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeExportName(contact: FullContact): string {
|
||||
const name = fullContactDisplayName(contact) || contact.emails[0]?.value || "contact"
|
||||
return name.replace(/[/\\?%*:|"<>]/g, "-").trim() || "contact"
|
||||
}
|
||||
|
||||
function ContactTableRow({
|
||||
contact,
|
||||
selected,
|
||||
onToggleSelect,
|
||||
onOpen,
|
||||
}: {
|
||||
contact: FullContact
|
||||
selected: boolean
|
||||
onToggleSelect: (checked: boolean) => void
|
||||
onOpen: () => void
|
||||
}) {
|
||||
const displayName = fullContactDisplayName(contact)
|
||||
const name = displayName || contact.emails[0]?.value || contact.phones[0]?.value || "?"
|
||||
const color = avatarColor(name)
|
||||
const initial = senderInitial(name)
|
||||
const labelRows = useNavStore((s) => s.labelRows)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
onOpen()
|
||||
}
|
||||
}}
|
||||
className={`${TABLE_GRID} w-full cursor-pointer items-center border-b border-gray-100 py-2.5 text-left text-sm transition-colors hover:bg-[#f5f5f5] ${
|
||||
selected ? "bg-[#e8f0fe]" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => onToggleSelect(checked === true)}
|
||||
aria-label={`Sélectionner ${name}`}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-3">
|
||||
{contact.avatarUrl ? (
|
||||
<img src={contact.avatarUrl} alt={name} className="h-8 w-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[#1f1f1f]">{name}</span>
|
||||
</span>
|
||||
|
||||
<span className="truncate text-[#1f1f1f]">{contact.emails[0]?.value || ""}</span>
|
||||
<span className="truncate text-[#1f1f1f]">{contact.phones[0]?.value || ""}</span>
|
||||
<span className="truncate text-[#1f1f1f]">
|
||||
{[contact.jobTitle, contact.company].filter(Boolean).join(", ")}
|
||||
</span>
|
||||
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{contact.labels?.map((labelId) => {
|
||||
const row = labelRows.find((l) => l.id === labelId)
|
||||
return row ? (
|
||||
<span
|
||||
key={labelId}
|
||||
className="inline-flex rounded border border-gray-300 px-1.5 py-0.5 text-[11px] text-[#3c4043]"
|
||||
>
|
||||
{row.label}
|
||||
</span>
|
||||
) : null
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
153
components/gmail/contacts-page/import-dialog.tsx
Normal file
153
components/gmail/contacts-page/import-dialog.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Info } from "lucide-react"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { parseContactFile } from "@/lib/contacts/import-parsers"
|
||||
|
||||
interface ImportDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const addContacts = useContactsStore((s) => s.addContacts)
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null)
|
||||
const [previewCount, setPreviewCount] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
function resetState() {
|
||||
setPendingFile(null)
|
||||
setPreviewCount(0)
|
||||
setError(null)
|
||||
setImporting(false)
|
||||
if (fileRef.current) fileRef.current.value = ""
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) resetState()
|
||||
onOpenChange(next)
|
||||
}
|
||||
|
||||
function handleFileSelect() {
|
||||
fileRef.current?.click()
|
||||
}
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setError(null)
|
||||
setPendingFile(file)
|
||||
|
||||
try {
|
||||
const parsed = await parseContactFile(file)
|
||||
setPreviewCount(parsed.length)
|
||||
if (parsed.length === 0) {
|
||||
setError("Aucun contact trouvé dans ce fichier.")
|
||||
}
|
||||
} catch {
|
||||
setError("Impossible de lire ce fichier.")
|
||||
setPreviewCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!pendingFile || previewCount === 0) return
|
||||
|
||||
setImporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const parsed = await parseContactFile(pendingFile)
|
||||
const count = addContacts(parsed)
|
||||
if (count === 0) {
|
||||
setError("Aucun contact importé.")
|
||||
return
|
||||
}
|
||||
handleOpenChange(false)
|
||||
} catch {
|
||||
setError("L'import a échoué. Vérifiez le format du fichier.")
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Importer des contacts</DialogTitle>
|
||||
<Info className="h-5 w-5 text-[#5f6368]" />
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<p className="text-sm text-[#3c4043]">
|
||||
Pour commencer, sélectionnez un fichier.
|
||||
<br />
|
||||
Utilisez le format CSV ou vCard (.vcf).
|
||||
</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleFileSelect}
|
||||
className="rounded-full bg-[#1a73e8] px-5 text-sm font-medium text-white hover:bg-[#1557b0]"
|
||||
>
|
||||
Sélectionner un fichier
|
||||
</Button>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv,.vcf,.vcard,text/vcard,text/csv"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{pendingFile && previewCount > 0 && (
|
||||
<p className="text-sm text-[#1f1f1f]">
|
||||
{previewCount} contact{previewCount > 1 ? "s" : ""} prêt
|
||||
{previewCount > 1 ? "s" : ""} à importer depuis{" "}
|
||||
<span className="font-medium">{pendingFile.name}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<p className="text-xs text-[#5f6368]">
|
||||
Vous essayez de sauvegarder les contacts de votre mobile ?
|
||||
<br />
|
||||
<span className="cursor-pointer text-[#1a73e8]">Voici comment les synchroniser.</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
className="text-sm font-medium text-[#1a73e8]"
|
||||
>
|
||||
Non, ne rien faire
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
disabled={!pendingFile || previewCount === 0 || importing}
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{importing ? "Importation…" : "Importer"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
1
components/gmail/contacts-page/index.ts
Normal file
1
components/gmail/contacts-page/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { ContactsAppShell } from "./contacts-app-shell"
|
||||
223
components/gmail/contacts-page/merge-duplicates-view.tsx
Normal file
223
components/gmail/contacts-page/merge-duplicates-view.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useContactsStore, type MergeSuggestion } from "@/lib/contacts/contacts-store"
|
||||
import { findDuplicatePairs, type DuplicateMatchReason } from "@/lib/contacts/duplicate-detection"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
import { AddCoordinatesView } from "./add-coordinates-view"
|
||||
|
||||
type SubView = "merge" | "coordinates"
|
||||
|
||||
const REASON_LABELS: Record<DuplicateMatchReason, string> = {
|
||||
email: "Même adresse e-mail",
|
||||
phone: "Même numéro de téléphone",
|
||||
name: "Nom similaire",
|
||||
}
|
||||
|
||||
export function MergeDuplicatesView() {
|
||||
const [subView, setSubView] = useState<SubView>("merge")
|
||||
const contacts = useContactsStore((s) => s.contacts)
|
||||
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
|
||||
const mergeContacts = useContactsStore((s) => s.mergeContacts)
|
||||
const ignoreMergePair = useContactsStore((s) => s.ignoreMergePair)
|
||||
const getCoordinateSuggestions = useContactsStore((s) => s.getCoordinateSuggestions)
|
||||
|
||||
const mergeSuggestions = useMemo(
|
||||
() => findDuplicatePairs(contacts, new Set(ignoredMergePairs)),
|
||||
[contacts, ignoredMergePairs]
|
||||
)
|
||||
|
||||
const coordSuggestions = useMemo(
|
||||
() => getCoordinateSuggestions(),
|
||||
[getCoordinateSuggestions, contacts]
|
||||
)
|
||||
|
||||
const [mergingAll, setMergingAll] = useState(false)
|
||||
|
||||
function handleMerge(suggestion: MergeSuggestion) {
|
||||
mergeContacts(suggestion.contactA.id, suggestion.contactB.id)
|
||||
}
|
||||
|
||||
function handleIgnore(suggestion: MergeSuggestion) {
|
||||
ignoreMergePair(suggestion.contactA.id, suggestion.contactB.id)
|
||||
}
|
||||
|
||||
function handleMergeAll() {
|
||||
setMergingAll(true)
|
||||
try {
|
||||
let pairs = findDuplicatePairs(
|
||||
useContactsStore.getState().contacts,
|
||||
new Set(useContactsStore.getState().ignoredMergePairs)
|
||||
)
|
||||
while (pairs.length > 0) {
|
||||
const { contactA, contactB } = pairs[0]
|
||||
mergeContacts(contactA.id, contactB.id)
|
||||
const state = useContactsStore.getState()
|
||||
pairs = findDuplicatePairs(state.contacts, new Set(state.ignoredMergePairs))
|
||||
}
|
||||
} finally {
|
||||
setMergingAll(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 py-6">
|
||||
<div className="mb-6 flex items-start gap-4 rounded-xl bg-[#f0f4f9] p-5">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[#d3e3fd]">
|
||||
<span className="text-2xl">🧹</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-medium text-[#1f1f1f]">
|
||||
Des méthodes simples pour nettoyer vos contacts
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[#5f6368]">
|
||||
Obtenez de l'aide pour fusionner les contacts en double, ajouter des informations utiles, et bien encore
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubView("merge")}
|
||||
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
|
||||
subView === "merge"
|
||||
? "bg-[#c2e7ff] text-[#001d35]"
|
||||
: "bg-[#f0f4f9] text-[#1f1f1f] hover:bg-[#e3e8ed]"
|
||||
}`}
|
||||
>
|
||||
Fusionner les doublons
|
||||
{mergeSuggestions.length > 0 && (
|
||||
<span className="ml-2 text-xs">({mergeSuggestions.length})</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubView("coordinates")}
|
||||
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
|
||||
subView === "coordinates"
|
||||
? "bg-[#c2e7ff] text-[#001d35]"
|
||||
: "bg-[#f0f4f9] text-[#1f1f1f] hover:bg-[#e3e8ed]"
|
||||
}`}
|
||||
>
|
||||
Ajouter des coordonnées
|
||||
{coordSuggestions.length > 0 && (
|
||||
<span className="ml-2 text-xs">({coordSuggestions.length})</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{subView === "merge" && (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-normal text-[#1f1f1f]">
|
||||
Fusionner les doublons ({mergeSuggestions.length})
|
||||
</h3>
|
||||
{mergeSuggestions.length > 0 && (
|
||||
<Button
|
||||
onClick={handleMergeAll}
|
||||
disabled={mergingAll}
|
||||
className="rounded-full bg-[#1a73e8] px-5 text-sm font-medium text-white hover:bg-[#1557b0]"
|
||||
>
|
||||
{mergingAll ? "Fusion…" : "Tout fusionner"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mergeSuggestions.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-[#5f6368]">
|
||||
Aucun doublon détecté
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{mergeSuggestions.map((suggestion) => (
|
||||
<MergeSuggestionCard
|
||||
key={`${suggestion.contactA.id}:${suggestion.contactB.id}`}
|
||||
suggestion={suggestion}
|
||||
onMerge={() => handleMerge(suggestion)}
|
||||
onIgnore={() => handleIgnore(suggestion)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subView === "coordinates" && <AddCoordinatesView />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MergeSuggestionCard({
|
||||
suggestion,
|
||||
onMerge,
|
||||
onIgnore,
|
||||
}: {
|
||||
suggestion: MergeSuggestion
|
||||
onMerge: () => void
|
||||
onIgnore: () => void
|
||||
}) {
|
||||
const { contactA, contactB, reason } = suggestion
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 p-5">
|
||||
<p className="mb-3 text-xs font-medium text-[#5f6368]">
|
||||
{REASON_LABELS[reason]}
|
||||
</p>
|
||||
<div className="flex items-start gap-6">
|
||||
<ContactMiniCard contact={contactA} />
|
||||
<ContactMiniCard contact={contactB} />
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onIgnore}
|
||||
className="text-sm font-medium text-[#1a73e8] hover:text-[#1557b0]"
|
||||
>
|
||||
Ignorer
|
||||
</button>
|
||||
<Button
|
||||
onClick={onMerge}
|
||||
className="rounded-full bg-[#1a73e8] px-5 text-sm font-medium text-white hover:bg-[#1557b0]"
|
||||
>
|
||||
Fusionner
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContactMiniCard({ contact }: { contact: import("@/lib/contacts/types").FullContact }) {
|
||||
const displayName = fullContactDisplayName(contact)
|
||||
const name = displayName || contact.emails[0]?.value || "?"
|
||||
const color = avatarColor(name)
|
||||
const initial = senderInitial(name)
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-start gap-3">
|
||||
{contact.avatarUrl ? (
|
||||
<img src={contact.avatarUrl} alt={name} className="h-10 w-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[#1f1f1f]">{name}</p>
|
||||
{contact.emails[0] && (
|
||||
<p className="truncate text-xs text-[#5f6368]">{contact.emails[0].value}</p>
|
||||
)}
|
||||
{contact.phones[0] && (
|
||||
<p className="truncate text-xs text-[#5f6368]">
|
||||
{contact.phones[0].value} ({contact.phones[0].label})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
components/gmail/contacts-page/trash-view.tsx
Normal file
119
components/gmail/contacts-page/trash-view.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { MoreVertical, RotateCcw, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import { avatarColor, senderInitial } from "@/lib/sender-display"
|
||||
|
||||
export function TrashView() {
|
||||
const { deletedContacts, restoreContact, emptyTrash } = useContactsStore()
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 py-4">
|
||||
{/* Warning banner */}
|
||||
{deletedContacts.length > 0 && (
|
||||
<div className="mb-4 flex items-center justify-between rounded-lg bg-[#fef7e0] px-4 py-3">
|
||||
<p className="text-sm text-[#3c4043]">
|
||||
Les contacts qui sont dans la corbeille depuis plus de 30 jours seront supprimés définitivement
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={emptyTrash}
|
||||
className="shrink-0 text-sm font-medium text-[#1a73e8] hover:text-[#1557b0]"
|
||||
>
|
||||
Vider la corbeille
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="mb-4 text-2xl font-normal text-[#1f1f1f]">
|
||||
Corbeille ({deletedContacts.length})
|
||||
</h1>
|
||||
|
||||
{deletedContacts.length === 0 && (
|
||||
<p className="py-12 text-center text-sm text-[#5f6368]">
|
||||
La corbeille est vide
|
||||
</p>
|
||||
)}
|
||||
|
||||
{deletedContacts.length > 0 && (
|
||||
<>
|
||||
{/* Table header */}
|
||||
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1fr)_40px] gap-2 border-b border-gray-200 py-2 text-xs font-medium text-[#5f6368]">
|
||||
<span>Nom</span>
|
||||
<span>Raison du placement dans la corbeille</span>
|
||||
<span>Date de suppression</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{deletedContacts.map((entry) => {
|
||||
const { contact, deletedAt, reason } = entry
|
||||
const displayName = fullContactDisplayName(contact)
|
||||
const name = displayName || contact.emails[0]?.value || "?"
|
||||
const color = avatarColor(name)
|
||||
const initial = senderInitial(name)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={contact.id}
|
||||
className="grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1fr)_40px] items-center gap-2 border-b border-gray-100 py-3 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
{contact.avatarUrl ? (
|
||||
<img src={contact.avatarUrl} alt={name} className="h-8 w-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[#1f1f1f]">{name}</span>
|
||||
</span>
|
||||
<span className="truncate text-[#5f6368]">{reason}</span>
|
||||
<span className="text-[#5f6368]">{formatDate(deletedAt)}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full text-[#5f6368]">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => restoreContact(contact.id)}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Restaurer
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => useContactsStore.getState().deleteContact(contact.id)}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Supprimer définitivement
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
"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"
|
||||
@ -119,9 +120,9 @@ export function ContactsListView() {
|
||||
className="h-8 w-8 rounded-full text-gray-600"
|
||||
asChild
|
||||
>
|
||||
<a href="https://contacts.google.com" target="_blank" rel="noopener noreferrer">
|
||||
<Link href="/contacts">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
106
components/gmail/header-account-actions.tsx
Normal file
106
components/gmail/header-account-actions.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Icon, addCollection } from "@iconify/react"
|
||||
import { icons as mdiIcons } from "@iconify-json/mdi"
|
||||
import { Pencil } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
addCollection(mdiIcons)
|
||||
|
||||
const googleApps = [
|
||||
{ name: "Compte", icon: "/compte-mark.svg" },
|
||||
{ name: "Agenda", icon: "/agenda-mark.svg" },
|
||||
{ name: "Photos", icon: "/photos-mark.svg" },
|
||||
{ name: "Ultimail", icon: "/brand/ultimail-header-icon.png" },
|
||||
{ name: "UltiDrive", icon: "/ultidrive-mark.svg" },
|
||||
{ name: "UltiMeet", icon: "/ultimeet-mark.svg" },
|
||||
{ name: "Administration", icon: "/admin-mark.svg" },
|
||||
{ name: "OpenMaps", icon: "/openstreetmap-mark.svg" },
|
||||
{ name: "Mistral", icon: "/mistral-mark.svg" },
|
||||
{ name: "Qwant", icon: "/qwant-mark.svg" },
|
||||
{ name: "Ground News", icon: "/ground-news-mark.svg" },
|
||||
]
|
||||
|
||||
interface HeaderAccountActionsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
|
||||
const [appsMenuOpen, setAppsMenuOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setAppsMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={cn("flex shrink-0 items-center gap-1", className)}>
|
||||
<Button variant="ghost" size="icon" className="hidden text-gray-600 sm:inline-flex" aria-label="Aide">
|
||||
<Icon icon="mdi:help-circle-outline" className="size-6 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="text-gray-600" aria-label="Réglages">
|
||||
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
|
||||
<div className="relative hidden sm:block" ref={menuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-600"
|
||||
aria-label="Applications"
|
||||
onClick={() => setAppsMenuOpen(!appsMenuOpen)}
|
||||
>
|
||||
<Icon icon="mdi:view-grid-outline" className="size-6 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
|
||||
{appsMenuOpen && (
|
||||
<div className="absolute right-0 top-12 z-50 w-96 rounded-2xl border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 p-4">
|
||||
<span className="text-lg font-normal text-gray-800">Vos favoris</span>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-600">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 p-3">
|
||||
{googleApps.map((app) => (
|
||||
<button
|
||||
key={app.name}
|
||||
type="button"
|
||||
className="flex flex-col items-center gap-2 rounded-lg p-3 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center">
|
||||
<img
|
||||
src={app.icon}
|
||||
alt={app.name}
|
||||
className="h-10 w-10 object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
target.parentElement!.innerHTML = `<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500 font-bold text-white">${app.name[0]}</div>`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-full text-center text-xs text-gray-700">{app.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon-lg" className="ml-2 size-11 overflow-hidden rounded-full p-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-purple-500 text-base font-bold text-white">
|
||||
E
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,14 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Icon, addCollection } from "@iconify/react"
|
||||
import { icons as mdiIcons } from "@iconify-json/mdi"
|
||||
import { Menu, Search, Pencil } from "lucide-react"
|
||||
import { Menu, Search } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
addCollection(mdiIcons)
|
||||
import { UltiMailLogo } from "@/components/ultimail-logo"
|
||||
import { MailSearchBar } from "@/components/gmail/mail-search-bar"
|
||||
import { HeaderAccountActions } from "@/components/gmail/header-account-actions"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface HeaderProps {
|
||||
@ -20,39 +16,12 @@ interface HeaderProps {
|
||||
hideSearch?: boolean
|
||||
}
|
||||
|
||||
const googleApps = [
|
||||
{ name: "Compte", icon: "/compte-mark.svg" },
|
||||
{ name: "Agenda", icon: "/agenda-mark.svg" },
|
||||
{ name: "Photos", icon: "/photos-mark.svg" },
|
||||
{ name: "Ultimail", icon: "/brand/ultimail-header-icon.png" },
|
||||
{ name: "UltiDrive", icon: "/ultidrive-mark.svg" },
|
||||
{ name: "UltiMeet", icon: "/ultimeet-mark.svg" },
|
||||
{ name: "Administration", icon: "/admin-mark.svg" },
|
||||
{ name: "OpenMaps", icon: "/openstreetmap-mark.svg" },
|
||||
{ name: "Mistral", icon: "/mistral-mark.svg" },
|
||||
{ name: "Qwant", icon: "/qwant-mark.svg" },
|
||||
{ name: "Ground News", icon: "/ground-news-mark.svg" },
|
||||
]
|
||||
|
||||
export function Header({
|
||||
onToggleSidebar,
|
||||
sidebarCollapsed,
|
||||
isXs = false,
|
||||
hideSearch = false,
|
||||
}: HeaderProps) {
|
||||
const [appsMenuOpen, setAppsMenuOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setAppsMenuOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<header className="flex h-16 w-full min-w-0 items-center gap-0 bg-app-canvas pl-0 pr-4 sm:gap-2">
|
||||
{/* Rail width = page spacer so search left edge lines up with `<main>`. */}
|
||||
@ -100,64 +69,7 @@ export function Header({
|
||||
<UltiMailLogo className="min-h-8 shrink-0 hidden sm:flex" />
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="hidden text-gray-600 sm:inline-flex" aria-label="Aide">
|
||||
<Icon icon="mdi:help-circle-outline" className="size-6 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="text-gray-600" aria-label="Réglages">
|
||||
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
|
||||
{/* Google Apps Menu */}
|
||||
<div className="relative hidden sm:block" ref={menuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-600"
|
||||
aria-label="Applications"
|
||||
onClick={() => setAppsMenuOpen(!appsMenuOpen)}
|
||||
>
|
||||
<Icon icon="mdi:view-grid-outline" className="size-6 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
|
||||
{appsMenuOpen && (
|
||||
<div className="absolute right-0 top-12 z-50 w-96 rounded-2xl border border-gray-200 bg-white shadow-xl">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<span className="text-lg font-normal text-gray-800">Vos favoris</span>
|
||||
<Button variant="ghost" size="icon" className="text-gray-600 h-8 w-8">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 p-3">
|
||||
{googleApps.map((app) => (
|
||||
<button
|
||||
key={app.name}
|
||||
className="flex flex-col items-center gap-2 rounded-lg p-3 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="h-10 w-10 flex items-center justify-center">
|
||||
<img
|
||||
src={app.icon}
|
||||
alt={app.name}
|
||||
className="h-10 w-10 object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
target.parentElement!.innerHTML = `<div class="h-10 w-10 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold">${app.name[0]}</div>`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-700 w-full text-center">{app.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon-lg" className="size-11 rounded-full overflow-hidden ml-2 p-0">
|
||||
<div className="h-10 w-10 rounded-full bg-purple-500 flex items-center justify-center text-white text-base font-bold">
|
||||
E
|
||||
</div>
|
||||
</Button>
|
||||
<HeaderAccountActions />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@ -3,6 +3,12 @@
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||
import {
|
||||
findDuplicatePairs,
|
||||
mergePairKey,
|
||||
normalizePhone,
|
||||
type DuplicateMatchReason,
|
||||
} from "./duplicate-detection"
|
||||
import { MOCK_FULL_CONTACTS } from "./mock-data"
|
||||
import type { FullContact } from "./types"
|
||||
|
||||
@ -15,8 +21,28 @@ export type ContactCreateDraft = {
|
||||
emails?: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
export interface DeletedContact {
|
||||
contact: FullContact
|
||||
deletedAt: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface MergeSuggestion {
|
||||
contactA: FullContact
|
||||
contactB: FullContact
|
||||
reason: DuplicateMatchReason
|
||||
}
|
||||
|
||||
export interface CoordinateSuggestion {
|
||||
contact: FullContact
|
||||
suggestedField: string
|
||||
suggestedValue: string
|
||||
}
|
||||
|
||||
interface ContactsState {
|
||||
contacts: FullContact[]
|
||||
deletedContacts: DeletedContact[]
|
||||
ignoredMergePairs: string[]
|
||||
panelOpen: boolean
|
||||
view: ContactsView
|
||||
activeContactId: string | null
|
||||
@ -38,16 +64,60 @@ interface ContactsActions {
|
||||
addContact: (
|
||||
contact: Omit<FullContact, "id" | "createdAt" | "updatedAt">
|
||||
) => string
|
||||
addContacts: (
|
||||
contacts: Omit<FullContact, "id" | "createdAt" | "updatedAt">[]
|
||||
) => number
|
||||
updateContact: (id: string, patch: Partial<FullContact>) => void
|
||||
deleteContact: (id: string) => void
|
||||
softDeleteContact: (id: string, reason?: string) => void
|
||||
restoreContact: (id: string) => void
|
||||
emptyTrash: () => void
|
||||
mergeContacts: (keepId: string, mergeId: string) => void
|
||||
ignoreMergePair: (idA: string, idB: string) => void
|
||||
getMergeSuggestions: () => MergeSuggestion[]
|
||||
getCoordinateSuggestions: () => CoordinateSuggestion[]
|
||||
}
|
||||
|
||||
export type ContactsStore = ContactsState & ContactsActions
|
||||
|
||||
function computeCoordinateSuggestions(contacts: FullContact[]): CoordinateSuggestion[] {
|
||||
const suggestions: CoordinateSuggestion[] = []
|
||||
const emailDomains = new Map<string, { company?: string; jobTitle?: string }>()
|
||||
|
||||
for (const c of contacts) {
|
||||
if (c.company) {
|
||||
for (const e of c.emails) {
|
||||
const domain = e.value.split("@")[1]?.toLowerCase()
|
||||
if (domain && !domain.includes("gmail") && !domain.includes("outlook") && !domain.includes("yahoo") && !domain.includes("proton")) {
|
||||
emailDomains.set(domain, { company: c.company, jobTitle: c.jobTitle })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of contacts) {
|
||||
if (c.company) continue
|
||||
for (const e of c.emails) {
|
||||
const domain = e.value.split("@")[1]?.toLowerCase()
|
||||
if (domain && emailDomains.has(domain)) {
|
||||
const info = emailDomains.get(domain)!
|
||||
if (info.company) {
|
||||
suggestions.push({ contact: c, suggestedField: "company", suggestedValue: info.company })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (suggestions.length >= 20) break
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
export const useContactsStore = create<ContactsStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
(set, get) => ({
|
||||
contacts: MOCK_FULL_CONTACTS,
|
||||
deletedContacts: [],
|
||||
ignoredMergePairs: [],
|
||||
panelOpen: false,
|
||||
view: "list",
|
||||
activeContactId: null,
|
||||
@ -119,6 +189,19 @@ export const useContactsStore = create<ContactsStore>()(
|
||||
return id
|
||||
},
|
||||
|
||||
addContacts: (incoming) => {
|
||||
if (incoming.length === 0) return 0
|
||||
const now = Date.now()
|
||||
const added = incoming.map((contact) => ({
|
||||
...contact,
|
||||
id: `contact-${crypto.randomUUID()}`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
set((s) => ({ contacts: [...s.contacts, ...added] }))
|
||||
return added.length
|
||||
},
|
||||
|
||||
updateContact: (id, patch) =>
|
||||
set((s) => ({
|
||||
contacts: s.contacts.map((c) =>
|
||||
@ -132,11 +215,117 @@ export const useContactsStore = create<ContactsStore>()(
|
||||
activeContactId: s.activeContactId === id ? null : s.activeContactId,
|
||||
view: s.activeContactId === id ? "list" : s.view,
|
||||
})),
|
||||
|
||||
softDeleteContact: (id, reason = "Supprimé manuellement") =>
|
||||
set((s) => {
|
||||
const contact = s.contacts.find((c) => c.id === id)
|
||||
if (!contact) return s
|
||||
return {
|
||||
contacts: s.contacts.filter((c) => c.id !== id),
|
||||
deletedContacts: [
|
||||
...s.deletedContacts,
|
||||
{ contact, deletedAt: Date.now(), reason },
|
||||
],
|
||||
activeContactId: s.activeContactId === id ? null : s.activeContactId,
|
||||
view: s.activeContactId === id ? "list" : s.view,
|
||||
}
|
||||
}),
|
||||
|
||||
restoreContact: (id) =>
|
||||
set((s) => {
|
||||
const entry = s.deletedContacts.find((d) => d.contact.id === id)
|
||||
if (!entry) return s
|
||||
return {
|
||||
contacts: [...s.contacts, entry.contact],
|
||||
deletedContacts: s.deletedContacts.filter((d) => d.contact.id !== id),
|
||||
}
|
||||
}),
|
||||
|
||||
emptyTrash: () => set({ deletedContacts: [] }),
|
||||
|
||||
mergeContacts: (keepId, mergeId) =>
|
||||
set((s) => {
|
||||
const keep = s.contacts.find((c) => c.id === keepId)
|
||||
const merge = s.contacts.find((c) => c.id === mergeId)
|
||||
if (!keep || !merge) return s
|
||||
|
||||
const mergedEmails = [...keep.emails]
|
||||
for (const e of merge.emails) {
|
||||
if (!mergedEmails.some((me) => me.value.toLowerCase() === e.value.toLowerCase())) {
|
||||
mergedEmails.push(e)
|
||||
}
|
||||
}
|
||||
const mergedPhones = [...keep.phones]
|
||||
for (const p of merge.phones) {
|
||||
const norm = normalizePhone(p.value)
|
||||
if (
|
||||
!mergedPhones.some(
|
||||
(mp) => normalizePhone(mp.value) === norm && norm.length > 0
|
||||
)
|
||||
) {
|
||||
mergedPhones.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
const mergedLabels = [
|
||||
...new Set([...(keep.labels ?? []), ...(merge.labels ?? [])]),
|
||||
]
|
||||
|
||||
const merged: FullContact = {
|
||||
...keep,
|
||||
firstName: keep.firstName || merge.firstName,
|
||||
lastName: keep.lastName || merge.lastName,
|
||||
emails: mergedEmails,
|
||||
phones: mergedPhones,
|
||||
labels: mergedLabels.length ? mergedLabels : undefined,
|
||||
company: keep.company || merge.company,
|
||||
jobTitle: keep.jobTitle || merge.jobTitle,
|
||||
department: keep.department || merge.department,
|
||||
birthday: keep.birthday || merge.birthday,
|
||||
avatarUrl: keep.avatarUrl || merge.avatarUrl,
|
||||
notes: [keep.notes, merge.notes].filter(Boolean).join("\n") || undefined,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
const pairKey = mergePairKey(keepId, mergeId)
|
||||
|
||||
return {
|
||||
contacts: s.contacts
|
||||
.filter((c) => c.id !== mergeId)
|
||||
.map((c) => (c.id === keepId ? merged : c)),
|
||||
ignoredMergePairs: s.ignoredMergePairs.includes(pairKey)
|
||||
? s.ignoredMergePairs
|
||||
: [...s.ignoredMergePairs, pairKey],
|
||||
}
|
||||
}),
|
||||
|
||||
ignoreMergePair: (idA, idB) =>
|
||||
set((s) => {
|
||||
const key = mergePairKey(idA, idB)
|
||||
if (s.ignoredMergePairs.includes(key)) return s
|
||||
return { ignoredMergePairs: [...s.ignoredMergePairs, key] }
|
||||
}),
|
||||
|
||||
getMergeSuggestions: () => {
|
||||
const s = get()
|
||||
const ignored = new Set(s.ignoredMergePairs)
|
||||
return findDuplicatePairs(s.contacts, ignored).map((p) => ({
|
||||
contactA: p.contactA,
|
||||
contactB: p.contactB,
|
||||
reason: p.reason,
|
||||
}))
|
||||
},
|
||||
|
||||
getCoordinateSuggestions: () => computeCoordinateSuggestions(get().contacts),
|
||||
}),
|
||||
{
|
||||
name: "contacts-store",
|
||||
storage: debouncedPersistJSONStorage,
|
||||
partialize: (state) => ({ contacts: state.contacts }),
|
||||
partialize: (state) => ({
|
||||
contacts: state.contacts,
|
||||
deletedContacts: state.deletedContacts,
|
||||
ignoredMergePairs: state.ignoredMergePairs,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
137
lib/contacts/duplicate-detection.ts
Normal file
137
lib/contacts/duplicate-detection.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import type { FullContact } from "./types"
|
||||
import { fullContactDisplayName } from "./types"
|
||||
import { normalizeEmail } from "./find-contact"
|
||||
|
||||
export type DuplicateMatchReason = "email" | "phone" | "name"
|
||||
|
||||
export interface DuplicatePair {
|
||||
contactA: FullContact
|
||||
contactB: FullContact
|
||||
reason: DuplicateMatchReason
|
||||
}
|
||||
|
||||
/** Max Levenshtein distance for short strings (exact cap). */
|
||||
const MAX_NAME_DISTANCE = 2
|
||||
/** Min similarity ratio (1 - distance/maxLen) for longer names. */
|
||||
const MIN_NAME_SIMILARITY = 0.88
|
||||
const MIN_NAME_LENGTH_FOR_FUZZY = 4
|
||||
const MIN_PHONE_DIGITS = 6
|
||||
|
||||
export function levenshteinDistance(a: string, b: string): number {
|
||||
if (a === b) return 0
|
||||
if (!a.length) return b.length
|
||||
if (!b.length) return a.length
|
||||
|
||||
const rows = a.length + 1
|
||||
const cols = b.length + 1
|
||||
let prev = Array.from({ length: cols }, (_, i) => i)
|
||||
let curr = new Array<number>(cols)
|
||||
|
||||
for (let i = 1; i < rows; i++) {
|
||||
curr[0] = i
|
||||
for (let j = 1; j < cols; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
|
||||
}
|
||||
;[prev, curr] = [curr, prev]
|
||||
}
|
||||
return prev[b.length]
|
||||
}
|
||||
|
||||
export function normalizeContactName(name: string): string {
|
||||
return name
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function normalizePhone(phone: string): string {
|
||||
return phone.replace(/\D/g, "")
|
||||
}
|
||||
|
||||
export function areNamesSimilar(a: string, b: string): boolean {
|
||||
const na = normalizeContactName(a)
|
||||
const nb = normalizeContactName(b)
|
||||
if (!na || !nb) return false
|
||||
if (na === nb) return true
|
||||
|
||||
const maxLen = Math.max(na.length, nb.length)
|
||||
if (maxLen < MIN_NAME_LENGTH_FOR_FUZZY) return false
|
||||
|
||||
const distance = levenshteinDistance(na, nb)
|
||||
if (distance <= MAX_NAME_DISTANCE) return true
|
||||
|
||||
const similarity = 1 - distance / maxLen
|
||||
return similarity >= MIN_NAME_SIMILARITY
|
||||
}
|
||||
|
||||
export function mergePairKey(idA: string, idB: string): string {
|
||||
return idA < idB ? `${idA}|${idB}` : `${idB}|${idA}`
|
||||
}
|
||||
|
||||
function collectEmails(c: FullContact): string[] {
|
||||
return c.emails.map((e) => normalizeEmail(e.value)).filter(Boolean)
|
||||
}
|
||||
|
||||
function collectPhones(c: FullContact): string[] {
|
||||
return c.phones
|
||||
.map((p) => normalizePhone(p.value))
|
||||
.filter((d) => d.length >= MIN_PHONE_DIGITS)
|
||||
}
|
||||
|
||||
function findDuplicateReason(a: FullContact, b: FullContact): DuplicateMatchReason | null {
|
||||
const emailsA = collectEmails(a)
|
||||
const emailsB = collectEmails(b)
|
||||
for (const ea of emailsA) {
|
||||
if (emailsB.includes(ea)) return "email"
|
||||
}
|
||||
|
||||
const phonesA = collectPhones(a)
|
||||
const phonesB = collectPhones(b)
|
||||
for (const pa of phonesA) {
|
||||
for (const pb of phonesB) {
|
||||
if (pa === pb) return "phone"
|
||||
if (pa.length >= MIN_PHONE_DIGITS && pb.length >= MIN_PHONE_DIGITS) {
|
||||
const dist = levenshteinDistance(pa, pb)
|
||||
const maxLen = Math.max(pa.length, pb.length)
|
||||
if (dist <= 1 || 1 - dist / maxLen >= 0.95) return "phone"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nameA = fullContactDisplayName(a)
|
||||
const nameB = fullContactDisplayName(b)
|
||||
if (nameA && nameB && areNamesSimilar(nameA, nameB)) return "name"
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function findDuplicatePairs(
|
||||
contacts: FullContact[],
|
||||
ignoredKeys: ReadonlySet<string> = new Set(),
|
||||
maxResults = 50
|
||||
): DuplicatePair[] {
|
||||
const results: DuplicatePair[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (let i = 0; i < contacts.length; i++) {
|
||||
for (let j = i + 1; j < contacts.length; j++) {
|
||||
const a = contacts[i]
|
||||
const b = contacts[j]
|
||||
const key = mergePairKey(a.id, b.id)
|
||||
if (seen.has(key) || ignoredKeys.has(key)) continue
|
||||
|
||||
const reason = findDuplicateReason(a, b)
|
||||
if (reason) {
|
||||
seen.add(key)
|
||||
results.push({ contactA: a, contactB: b, reason })
|
||||
if (results.length >= maxResults) return results
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
118
lib/contacts/export-contacts.ts
Normal file
118
lib/contacts/export-contacts.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { fullContactDisplayName, type FullContact } from "./types"
|
||||
|
||||
function escapeVCardValue(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/;/g, "\\;")
|
||||
.replace(/,/g, "\\,")
|
||||
.replace(/\n/g, "\\n")
|
||||
}
|
||||
|
||||
function escapeCsvField(value: string): string {
|
||||
if (/[",\n\r]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[/\\?%*:|"<>]/g, "-").trim() || "contact"
|
||||
}
|
||||
|
||||
export function contactToVCard(contact: FullContact): string {
|
||||
const lines: string[] = ["BEGIN:VCARD", "VERSION:3.0"]
|
||||
|
||||
const last = contact.lastName ?? ""
|
||||
const first = contact.firstName ?? ""
|
||||
const middle = contact.middleName ?? ""
|
||||
const prefix = contact.namePrefix ?? ""
|
||||
const suffix = contact.nameSuffix ?? ""
|
||||
lines.push(
|
||||
`N:${escapeVCardValue(last)};${escapeVCardValue(first)};${escapeVCardValue(middle)};${escapeVCardValue(prefix)};${escapeVCardValue(suffix)}`
|
||||
)
|
||||
|
||||
const fn = fullContactDisplayName(contact) || contact.emails[0]?.value || contact.phones[0]?.value
|
||||
if (fn) lines.push(`FN:${escapeVCardValue(fn)}`)
|
||||
|
||||
for (const e of contact.emails) {
|
||||
if (e.value.trim()) {
|
||||
lines.push(`EMAIL;TYPE=${escapeVCardValue(e.label || "INTERNET")}:${escapeVCardValue(e.value.trim())}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of contact.phones) {
|
||||
if (p.value.trim()) {
|
||||
lines.push(`TEL;TYPE=${escapeVCardValue(p.label || "VOICE")}:${escapeVCardValue(p.value.trim())}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (contact.company?.trim()) {
|
||||
const org = contact.jobTitle?.trim()
|
||||
? `${escapeVCardValue(contact.company.trim())};${escapeVCardValue(contact.jobTitle.trim())}`
|
||||
: escapeVCardValue(contact.company.trim())
|
||||
lines.push(`ORG:${org}`)
|
||||
} else if (contact.jobTitle?.trim()) {
|
||||
lines.push(`TITLE:${escapeVCardValue(contact.jobTitle.trim())}`)
|
||||
}
|
||||
|
||||
if (contact.department?.trim()) {
|
||||
lines.push(`X-ABLabel:${escapeVCardValue(contact.department.trim())}`)
|
||||
}
|
||||
|
||||
if (contact.birthday?.month && contact.birthday?.day) {
|
||||
const y = contact.birthday.year ?? 1900
|
||||
const m = String(contact.birthday.month).padStart(2, "0")
|
||||
const d = String(contact.birthday.day).padStart(2, "0")
|
||||
lines.push(`BDAY:${y}${m}${d}`)
|
||||
}
|
||||
|
||||
if (contact.notes?.trim()) {
|
||||
lines.push(`NOTE:${escapeVCardValue(contact.notes.trim())}`)
|
||||
}
|
||||
|
||||
lines.push("END:VCARD")
|
||||
return lines.join("\r\n")
|
||||
}
|
||||
|
||||
export function contactsToVCard(contacts: FullContact[]): string {
|
||||
return contacts.map(contactToVCard).join("\r\n")
|
||||
}
|
||||
|
||||
export function contactsToCsv(contacts: FullContact[]): string {
|
||||
const header = ["Name", "Email", "Phone", "Company", "Job Title", "Notes"]
|
||||
const rows = contacts.map((c) => {
|
||||
const name = fullContactDisplayName(c)
|
||||
const email = c.emails.map((e) => e.value).join("; ")
|
||||
const phone = c.phones.map((p) => p.value).join("; ")
|
||||
const company = c.company ?? ""
|
||||
const jobTitle = c.jobTitle ?? ""
|
||||
const notes = c.notes ?? ""
|
||||
return [name, email, phone, company, jobTitle, notes].map(escapeCsvField).join(",")
|
||||
})
|
||||
return [header.join(","), ...rows].join("\r\n")
|
||||
}
|
||||
|
||||
export function downloadTextFile(content: string, filename: string, mimeType: string): void {
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function downloadContactVCard(contact: FullContact): void {
|
||||
const base = sanitizeFilename(
|
||||
fullContactDisplayName(contact) || contact.emails[0]?.value || "contact"
|
||||
)
|
||||
downloadTextFile(contactToVCard(contact), `${base}.vcf`, "text/vcard;charset=utf-8")
|
||||
}
|
||||
|
||||
export function downloadContactsVCard(contacts: FullContact[], filename = "contacts.vcf"): void {
|
||||
downloadTextFile(contactsToVCard(contacts), filename, "text/vcard;charset=utf-8")
|
||||
}
|
||||
|
||||
export function downloadContactsCsv(contacts: FullContact[], filename = "contacts.csv"): void {
|
||||
downloadTextFile(contactsToCsv(contacts), filename, "text/csv;charset=utf-8")
|
||||
}
|
||||
314
lib/contacts/import-parsers.ts
Normal file
314
lib/contacts/import-parsers.ts
Normal file
@ -0,0 +1,314 @@
|
||||
import { parseDisplayNameToNameParts } from "./find-contact"
|
||||
import type { FullContact } from "./types"
|
||||
|
||||
export type ContactImportInput = Omit<FullContact, "id" | "createdAt" | "updatedAt">
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
const t = value.trim()
|
||||
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
||||
return t.slice(1, -1).trim()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const fields: string[] = []
|
||||
let current = ""
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i]
|
||||
if (ch === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
current += '"'
|
||||
i++
|
||||
} else {
|
||||
inQuotes = !inQuotes
|
||||
}
|
||||
} else if (ch === "," && !inQuotes) {
|
||||
fields.push(stripQuotes(current))
|
||||
current = ""
|
||||
} else {
|
||||
current += ch
|
||||
}
|
||||
}
|
||||
fields.push(stripQuotes(current))
|
||||
return fields
|
||||
}
|
||||
|
||||
function unfoldVcardLines(text: string): string[] {
|
||||
const raw = text.split(/\r?\n/)
|
||||
const lines: string[] = []
|
||||
for (const line of raw) {
|
||||
if ((line.startsWith(" ") || line.startsWith("\t")) && lines.length > 0) {
|
||||
lines[lines.length - 1] += line.slice(1)
|
||||
} else {
|
||||
lines.push(line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function parseVcardProperty(line: string): { key: string; value: string } | null {
|
||||
const idx = line.indexOf(":")
|
||||
if (idx === -1) return null
|
||||
const left = line.slice(0, idx)
|
||||
const value = line.slice(idx + 1).trim()
|
||||
const key = left.split(";")[0].toUpperCase()
|
||||
return { key, value }
|
||||
}
|
||||
|
||||
function parseVcardBlock(lines: string[]): ContactImportInput | null {
|
||||
let firstName = ""
|
||||
let lastName = ""
|
||||
let company: string | undefined
|
||||
let jobTitle: string | undefined
|
||||
const emails: { value: string; label: string }[] = []
|
||||
const phones: { value: string; label: string }[] = []
|
||||
let notes: string | undefined
|
||||
|
||||
for (const line of lines) {
|
||||
const prop = parseVcardProperty(line)
|
||||
if (!prop || !prop.value) continue
|
||||
const { key, value } = prop
|
||||
|
||||
switch (key) {
|
||||
case "FN": {
|
||||
const parts = parseDisplayNameToNameParts(value)
|
||||
if (!firstName && !lastName) {
|
||||
firstName = parts.firstName
|
||||
lastName = parts.lastName
|
||||
}
|
||||
break
|
||||
}
|
||||
case "N": {
|
||||
const segments = value.split(";")
|
||||
lastName = segments[0]?.trim() ?? ""
|
||||
firstName = segments[1]?.trim() ?? ""
|
||||
break
|
||||
}
|
||||
case "EMAIL":
|
||||
emails.push({ value, label: "personal" })
|
||||
break
|
||||
case "TEL":
|
||||
phones.push({ value, label: "mobile" })
|
||||
break
|
||||
case "ORG": {
|
||||
const [co, title] = value.split(";")
|
||||
company = co?.trim() || undefined
|
||||
jobTitle = title?.trim() || undefined
|
||||
break
|
||||
}
|
||||
case "NOTE":
|
||||
notes = value
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstName && !lastName && emails.length === 0 && phones.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
jobTitle,
|
||||
emails,
|
||||
phones,
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseVCardText(text: string): ContactImportInput[] {
|
||||
const unfolded = unfoldVcardLines(text)
|
||||
const contacts: ContactImportInput[] = []
|
||||
let block: string[] = []
|
||||
let inCard = false
|
||||
|
||||
for (const line of unfolded) {
|
||||
const upper = line.trim().toUpperCase()
|
||||
if (upper === "BEGIN:VCARD") {
|
||||
inCard = true
|
||||
block = []
|
||||
continue
|
||||
}
|
||||
if (upper === "END:VCARD") {
|
||||
if (inCard) {
|
||||
const parsed = parseVcardBlock(block)
|
||||
if (parsed) contacts.push(parsed)
|
||||
}
|
||||
inCard = false
|
||||
block = []
|
||||
continue
|
||||
}
|
||||
if (inCard) block.push(line)
|
||||
}
|
||||
|
||||
return contacts
|
||||
}
|
||||
|
||||
function headerIndex(headers: string[], candidates: string[]): number {
|
||||
const lower = headers.map((h) => h.toLowerCase().trim())
|
||||
for (const c of candidates) {
|
||||
const i = lower.indexOf(c)
|
||||
if (i >= 0) return i
|
||||
}
|
||||
for (let i = 0; i < lower.length; i++) {
|
||||
if (candidates.some((c) => lower[i].includes(c))) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
export function parseCsvText(text: string): ContactImportInput[] {
|
||||
const lines = text.split(/\r?\n/).filter((l) => l.trim())
|
||||
if (lines.length === 0) return []
|
||||
|
||||
const firstFields = parseCsvLine(lines[0])
|
||||
const nameIdx = headerIndex(firstFields, ["name", "nom", "full name", "display name"])
|
||||
const emailIdx = headerIndex(firstFields, ["email", "e-mail", "mail"])
|
||||
const phoneIdx = headerIndex(firstFields, ["phone", "telephone", "tel", "mobile"])
|
||||
const firstIdx = headerIndex(firstFields, ["first name", "prénom", "prenom", "firstname"])
|
||||
const lastIdx = headerIndex(firstFields, ["last name", "nom", "lastname"])
|
||||
const companyIdx = headerIndex(firstFields, ["company", "organisation", "organization", "entreprise"])
|
||||
|
||||
const hasHeader = nameIdx >= 0 || emailIdx >= 0 || phoneIdx >= 0 || firstIdx >= 0
|
||||
const dataLines = hasHeader ? lines.slice(1) : lines
|
||||
|
||||
const contacts: ContactImportInput[] = []
|
||||
|
||||
for (const line of dataLines) {
|
||||
const fields = parseCsvLine(line)
|
||||
if (fields.every((f) => !f.trim())) continue
|
||||
|
||||
let firstName = ""
|
||||
let lastName = ""
|
||||
let company: string | undefined
|
||||
|
||||
if (hasHeader) {
|
||||
if (firstIdx >= 0) firstName = fields[firstIdx]?.trim() ?? ""
|
||||
if (lastIdx >= 0) lastName = fields[lastIdx]?.trim() ?? ""
|
||||
if (nameIdx >= 0 && !firstName && !lastName) {
|
||||
const parts = parseDisplayNameToNameParts(fields[nameIdx] ?? "")
|
||||
firstName = parts.firstName
|
||||
lastName = parts.lastName
|
||||
}
|
||||
if (companyIdx >= 0) company = fields[companyIdx]?.trim() || undefined
|
||||
} else if (fields.length === 1) {
|
||||
const entry = fields[0]
|
||||
if (entry.includes("@")) {
|
||||
contacts.push({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
emails: [{ value: entry.trim(), label: "personal" }],
|
||||
phones: [],
|
||||
})
|
||||
continue
|
||||
}
|
||||
const parts = parseDisplayNameToNameParts(entry)
|
||||
firstName = parts.firstName
|
||||
lastName = parts.lastName
|
||||
} else {
|
||||
const parts = parseDisplayNameToNameParts(fields[0] ?? "")
|
||||
firstName = parts.firstName
|
||||
lastName = parts.lastName
|
||||
}
|
||||
|
||||
const emails =
|
||||
hasHeader && emailIdx >= 0 && fields[emailIdx]?.trim()
|
||||
? [{ value: fields[emailIdx].trim(), label: "personal" }]
|
||||
: !hasHeader && fields[1]?.includes("@")
|
||||
? [{ value: fields[1].trim(), label: "personal" }]
|
||||
: []
|
||||
|
||||
const phones =
|
||||
hasHeader && phoneIdx >= 0 && fields[phoneIdx]?.trim()
|
||||
? [{ value: fields[phoneIdx].trim(), label: "mobile" }]
|
||||
: !hasHeader && fields[2]?.trim()
|
||||
? [{ value: fields[2].trim(), label: "mobile" }]
|
||||
: !hasHeader && fields[1]?.trim() && !fields[1].includes("@")
|
||||
? [{ value: fields[1].trim(), label: "mobile" }]
|
||||
: []
|
||||
|
||||
if (!firstName && !lastName && emails.length === 0 && phones.length === 0) continue
|
||||
|
||||
contacts.push({ firstName, lastName, company, emails, phones })
|
||||
}
|
||||
|
||||
return contacts
|
||||
}
|
||||
|
||||
/** Parse one bulk line: "Name", "email", or "Name <email>". */
|
||||
export function parseBulkContactLine(entry: string): ContactImportInput | null {
|
||||
const trimmed = entry.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const emailMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/)
|
||||
const email = emailMatch
|
||||
? emailMatch[2].trim()
|
||||
: trimmed.includes("@")
|
||||
? trimmed
|
||||
: ""
|
||||
const namePart = emailMatch
|
||||
? emailMatch[1].trim()
|
||||
: email && !trimmed.includes("@")
|
||||
? ""
|
||||
: trimmed
|
||||
|
||||
const { firstName, lastName } = parseDisplayNameToNameParts(namePart)
|
||||
|
||||
if (!firstName && !lastName && !email) return null
|
||||
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
emails: email ? [{ value: email, label: "personal" }] : [],
|
||||
phones: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Split bulk text by commas or newlines (respecting quoted segments). */
|
||||
export function parseBulkContactText(text: string): ContactImportInput[] {
|
||||
const entries: string[] = []
|
||||
let current = ""
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]
|
||||
if (ch === '"') {
|
||||
inQuotes = !inQuotes
|
||||
current += ch
|
||||
} else if ((ch === "," || ch === "\n" || ch === "\r") && !inQuotes) {
|
||||
if (current.trim()) entries.push(current.trim())
|
||||
current = ""
|
||||
if (ch === "\r" && text[i + 1] === "\n") i++
|
||||
} else {
|
||||
current += ch
|
||||
}
|
||||
}
|
||||
if (current.trim()) entries.push(current.trim())
|
||||
|
||||
const contacts: ContactImportInput[] = []
|
||||
for (const entry of entries) {
|
||||
const parsed = parseBulkContactLine(entry)
|
||||
if (parsed) contacts.push(parsed)
|
||||
}
|
||||
return contacts
|
||||
}
|
||||
|
||||
export async function parseContactFile(file: File): Promise<ContactImportInput[]> {
|
||||
const text = await file.text()
|
||||
const lower = file.name.toLowerCase()
|
||||
if (lower.endsWith(".vcf") || lower.endsWith(".vcard")) {
|
||||
return parseVCardText(text)
|
||||
}
|
||||
if (lower.endsWith(".csv")) {
|
||||
return parseCsvText(text)
|
||||
}
|
||||
if (text.includes("BEGIN:VCARD")) {
|
||||
return parseVCardText(text)
|
||||
}
|
||||
return parseCsvText(text)
|
||||
}
|
||||
@ -2,9 +2,36 @@ export { type FullContact, fullContactDisplayName, toComposeContact } from "./ty
|
||||
export { MOCK_FULL_CONTACTS } from "./mock-data"
|
||||
export { useContactsStore, type ContactsStore } from "./contacts-store"
|
||||
export { searchContacts } from "./fuzzy-search"
|
||||
export {
|
||||
findDuplicatePairs,
|
||||
levenshteinDistance,
|
||||
areNamesSimilar,
|
||||
normalizeContactName,
|
||||
normalizePhone,
|
||||
} from "./duplicate-detection"
|
||||
export {
|
||||
parseVCardText,
|
||||
parseCsvText,
|
||||
parseBulkContactText,
|
||||
parseContactFile,
|
||||
} from "./import-parsers"
|
||||
export { printContacts } from "./print-contacts"
|
||||
export {
|
||||
contactToVCard,
|
||||
contactsToVCard,
|
||||
contactsToCsv,
|
||||
downloadContactVCard,
|
||||
downloadContactsVCard,
|
||||
downloadContactsCsv,
|
||||
} from "./export-contacts"
|
||||
export {
|
||||
findContactByEmail,
|
||||
normalizeEmail,
|
||||
parseDisplayNameToNameParts,
|
||||
} from "./find-contact"
|
||||
export type { ContactCreateDraft } from "./contacts-store"
|
||||
export type {
|
||||
ContactCreateDraft,
|
||||
DeletedContact,
|
||||
MergeSuggestion,
|
||||
CoordinateSuggestion,
|
||||
} from "./contacts-store"
|
||||
|
||||
@ -24,23 +24,23 @@ function c(
|
||||
export const MOCK_FULL_CONTACTS: FullContact[] = [
|
||||
c({ firstName: "", lastName: "", phones: [{ value: "+216 91 108 533", label: "mobile" }], emails: [] }),
|
||||
c({ firstName: "Agence", lastName: "?", emails: [{ value: "contact@agence-question.fr", label: "work" }], phones: [] }),
|
||||
c({ firstName: "Adama", lastName: "Diallo", emails: [{ value: "adama.diallo@outlook.com", label: "personal" }], phones: [{ value: "+33 6 12 34 56 78", label: "mobile" }], company: "Senegal Tech", jobTitle: "Backend Engineer" }),
|
||||
c({ firstName: "Adrien", lastName: "Moreau", emails: [{ value: "adrien.moreau@gmail.com", label: "personal" }], phones: [], birthday: { day: 14, month: 3, year: 1992 } }),
|
||||
c({ firstName: "Aïcha", lastName: "Benmoussa", emails: [{ value: "aicha.benmoussa@yahoo.fr", label: "personal" }], phones: [{ value: "+212 6 61 23 45 67", label: "mobile" }], company: "Casablanca Media" }),
|
||||
c({ firstName: "Alexandre", lastName: "Netchaev", emails: [{ value: "alex.netchaev@gmail.com", label: "personal" }], phones: [{ value: "+33 6 45 67 89 01", label: "mobile" }], nicknames: ["Alex"] }),
|
||||
c({ firstName: "Adama", lastName: "Diallo", emails: [{ value: "adama.diallo@outlook.com", label: "personal" }], phones: [{ value: "+33 6 12 34 56 78", label: "mobile" }], company: "Senegal Tech", jobTitle: "Backend Engineer", interactionCount: 12 }),
|
||||
c({ firstName: "Adrien", lastName: "Moreau", emails: [{ value: "adrien.moreau@gmail.com", label: "personal" }], phones: [], birthday: { day: 14, month: 3, year: 1992 }, interactionCount: 5 }),
|
||||
c({ firstName: "Aïcha", lastName: "Benmoussa", emails: [{ value: "aicha.benmoussa@yahoo.fr", label: "personal" }], phones: [{ value: "+212 6 61 23 45 67", label: "mobile" }], company: "Casablanca Media", interactionCount: 8 }),
|
||||
c({ firstName: "Alexandre", lastName: "Netchaev", emails: [{ value: "alex.netchaev@gmail.com", label: "personal" }], phones: [{ value: "+33 6 45 67 89 01", label: "mobile" }], nicknames: ["Alex"], interactionCount: 45 }),
|
||||
c({ firstName: "Alix", lastName: "Macgregor", emails: [{ value: "alix.macgregor@proton.me", label: "personal" }], phones: [] }),
|
||||
c({ firstName: "Alizée", lastName: "Film", emails: [{ value: "alizee@alizeefilm.com", label: "work" }], phones: [{ value: "+33 1 42 33 44 55", label: "work" }], company: "Alizée Film", jobTitle: "Producer" }),
|
||||
c({ firstName: "Amadou", lastName: "Traoré", emails: [{ value: "amadou.traore@gmail.com", label: "personal" }], phones: [] }),
|
||||
c({ firstName: "Amadou", lastName: "Traoré", emails: [{ value: "amadou.traore@gmail.com", label: "personal" }], phones: [], isOtherContact: true }),
|
||||
c({ firstName: "Anaïs", lastName: "SATIS", emails: [{ value: "anais.satis@gmail.com", label: "personal" }], phones: [{ value: "+33 6 78 90 12 34", label: "mobile" }] }),
|
||||
c({ firstName: "Antoine", lastName: "Le Squerent", emails: [{ value: "antoine.lesquerent@gmail.com", label: "personal" }], phones: [], nicknames: ["Anto"] }),
|
||||
c({ firstName: "Armelle", lastName: "Loste", emails: [{ value: "armelle.loste@outlook.fr", label: "personal" }], phones: [{ value: "+33 6 11 22 33 44", label: "mobile" }] }),
|
||||
c({ firstName: "Antoine", lastName: "Le Squerent", emails: [{ value: "antoine.lesquerent@gmail.com", label: "personal" }], phones: [], nicknames: ["Anto"], interactionCount: 30 }),
|
||||
c({ firstName: "Armelle", lastName: "Loste", emails: [{ value: "armelle.loste@outlook.fr", label: "personal" }], phones: [{ value: "+33 6 11 22 33 44", label: "mobile" }], interactionCount: 22 }),
|
||||
c({ firstName: "Arthur", lastName: "Cohen", emails: [{ value: "arthur.cohen@gmail.com", label: "personal" }], phones: [], company: "Freelance", jobTitle: "Motion Designer" }),
|
||||
c({ firstName: "Augustin", lastName: "Ferrand", emails: [{ value: "augustin.ferrand@proton.me", label: "personal" }], phones: [] }),
|
||||
c({ firstName: "Augustin", lastName: "Ferrand", emails: [{ value: "augustin.ferrand@proton.me", label: "personal" }], phones: [], isOtherContact: true }),
|
||||
c({ firstName: "Aurélien", lastName: "Petit", emails: [{ value: "aurelien.petit@gmail.com", label: "personal" }], phones: [{ value: "+33 6 99 88 77 66", label: "mobile" }], birthday: { day: 22, month: 8, year: 1988 } }),
|
||||
c({ firstName: "Baptiste", lastName: "Roux", emails: [{ value: "baptiste.roux@yahoo.fr", label: "personal" }], phones: [] }),
|
||||
c({ firstName: "Baptiste", lastName: "Roux", emails: [{ value: "baptiste.roux@yahoo.fr", label: "personal" }], phones: [], isOtherContact: true }),
|
||||
c({ firstName: "Benjamin", lastName: "Budd", emails: [{ value: "benjamin.budd@gmail.com", label: "personal" }], phones: [{ value: "+44 7700 900123", label: "mobile" }] }),
|
||||
c({ firstName: "Billionaire", lastName: "Donald", emails: [{ value: "billionaire.donald@outlook.com", label: "personal" }], phones: [] }),
|
||||
c({ firstName: "Bruno", lastName: "RAFFY", emails: [{ value: "bruno.raffy@bpifrance.fr", label: "work" }], phones: [{ value: "+33 1 41 79 80 00", label: "work" }], company: "Bpifrance", jobTitle: "Investment Director" }),
|
||||
c({ firstName: "Bruno", lastName: "RAFFY", emails: [{ value: "bruno.raffy@bpifrance.fr", label: "work" }], phones: [{ value: "+33 1 41 79 80 00", label: "work" }], company: "Bpifrance", jobTitle: "Investment Director", interactionCount: 35 }),
|
||||
c({ firstName: "Camille", lastName: "Durand", emails: [{ value: "camille.durand@gmail.com", label: "personal" }], phones: [] }),
|
||||
c({ firstName: "Cédric", lastName: "Nguyen", emails: [{ value: "cedric.nguyen@outlook.com", label: "personal" }], phones: [{ value: "+33 6 55 44 33 22", label: "mobile" }], company: "VietTech Paris", jobTitle: "CTO" }),
|
||||
c({ firstName: "Charles", lastName: "Lefèvre", emails: [{ value: "charles.lefevre@proton.me", label: "personal" }], phones: [] }),
|
||||
@ -54,7 +54,7 @@ export const MOCK_FULL_CONTACTS: FullContact[] = [
|
||||
c({ firstName: "Diana", lastName: "Popescu", emails: [{ value: "diana.popescu@yahoo.com", label: "personal" }], phones: [{ value: "+40 721 234 567", label: "mobile" }], company: "Bucharest Digital" }),
|
||||
c({ firstName: "Djibril", lastName: "Sow", emails: [{ value: "djibril.sow@gmail.com", label: "personal" }], phones: [] }),
|
||||
c({ firstName: "Elena", lastName: "Kuznetsova", emails: [{ value: "elena.kuznetsova@mail.ru", label: "personal" }], phones: [{ value: "+7 916 123 45 67", label: "mobile" }] }),
|
||||
c({ firstName: "Eliott", lastName: "Guillaumin BL", emails: [{ value: "eliott@blacklight.tv", label: "work" }], phones: [{ value: "+33 6 29 25 16 98", label: "mobile" }], company: "Blacklight", jobTitle: "Technical Director", nicknames: ["Eli"] }),
|
||||
c({ firstName: "Eliott", lastName: "Guillaumin BL", emails: [{ value: "eliott@blacklight.tv", label: "work" }], phones: [{ value: "+33 6 29 25 16 98", label: "mobile" }], company: "Blacklight", jobTitle: "Technical Director", nicknames: ["Eli"], interactionCount: 67 }),
|
||||
c({ firstName: "Émilie", lastName: "Rousseau", emails: [{ value: "emilie.rousseau@gmail.com", label: "personal" }], phones: [{ value: "+33 6 88 77 66 55", label: "mobile" }] }),
|
||||
c({ firstName: "Emma", lastName: "Johansson", emails: [{ value: "emma.johansson@outlook.se", label: "personal" }], phones: [], company: "Spotify", jobTitle: "Product Manager" }),
|
||||
c({ firstName: "Étienne", lastName: "Marchand", emails: [{ value: "etienne.marchand@proton.me", label: "personal" }], phones: [] }),
|
||||
@ -75,8 +75,8 @@ export const MOCK_FULL_CONTACTS: FullContact[] = [
|
||||
c({ firstName: "Jade", lastName: "Renard", emails: [{ value: "jade.renard@gmail.com", label: "personal" }], phones: [] }),
|
||||
c({ firstName: "Javier", lastName: "Muñoz", emails: [{ value: "javier.munoz@gmail.com", label: "personal" }], phones: [{ value: "+34 612 345 678", label: "mobile" }], company: "Telefónica", jobTitle: "Data Scientist" }),
|
||||
c({ firstName: "Jean-Baptiste", lastName: "Vidal", emails: [{ value: "jb.vidal@gmail.com", label: "personal" }], phones: [{ value: "+33 6 44 55 66 77", label: "mobile" }], nicknames: ["JB"] }),
|
||||
c({ firstName: "Julien", lastName: "Carpentier", emails: [{ value: "julien.carpentier@proton.me", label: "personal" }], phones: [] }),
|
||||
c({ firstName: "Juliette", lastName: "Blanc", emails: [{ value: "juliette.blanc@gmail.com", label: "personal" }], phones: [{ value: "+33 6 00 11 22 33", label: "mobile" }], birthday: { day: 17, month: 9, year: 1993 } }),
|
||||
c({ firstName: "Julien", lastName: "Carpentier", emails: [{ value: "julien.carpentier@proton.me", label: "personal" }], phones: [], interactionCount: 18 }),
|
||||
c({ firstName: "Juliette", lastName: "Blanc", emails: [{ value: "juliette.blanc@gmail.com", label: "personal" }], phones: [{ value: "+33 6 00 11 22 33", label: "mobile" }], birthday: { day: 17, month: 9, year: 1993 }, interactionCount: 25 }),
|
||||
c({ firstName: "Karim", lastName: "Benzema", emails: [{ value: "karim.b@outlook.com", label: "personal" }], phones: [] }),
|
||||
c({ firstName: "Kenza", lastName: "Amiri", emails: [{ value: "kenza.amiri@gmail.com", label: "personal" }], phones: [{ value: "+33 6 12 98 76 54", label: "mobile" }] }),
|
||||
c({ firstName: "Kevin", lastName: "Park", emails: [{ value: "kevin.park@gmail.com", label: "personal" }], phones: [{ value: "+82 10 1234 5678", label: "mobile" }], company: "Samsung", jobTitle: "Frontend Engineer" }),
|
||||
@ -137,7 +137,7 @@ export const MOCK_FULL_CONTACTS: FullContact[] = [
|
||||
c({ firstName: "Valentin", lastName: "Perrin", emails: [{ value: "valentin.perrin@gmail.com", label: "personal" }], phones: [] }),
|
||||
c({ firstName: "Vanessa", lastName: "Cruz", emails: [{ value: "vanessa.cruz@gmail.com", label: "personal" }], phones: [{ value: "+55 21 99876 5432", label: "mobile" }] }),
|
||||
c({ firstName: "Victor", lastName: "Dupuis", emails: [{ value: "victor.dupuis@yahoo.fr", label: "personal" }], phones: [] }),
|
||||
c({ firstName: "Vincent", lastName: "Morel", emails: [{ value: "vincent.morel@gmail.com", label: "personal" }], phones: [{ value: "+33 6 65 78 90 12", label: "mobile" }], company: "BlaBlaCar", jobTitle: "VP Engineering" }),
|
||||
c({ firstName: "Vincent", lastName: "Morel", emails: [{ value: "vincent.morel@gmail.com", label: "personal" }], phones: [{ value: "+33 6 65 78 90 12", label: "mobile" }], company: "BlaBlaCar", jobTitle: "VP Engineering", interactionCount: 42 }),
|
||||
c({ firstName: "Wael", lastName: "Jbara", emails: [{ value: "wael.jbara@outlook.com", label: "personal" }], phones: [{ value: "+961 3 123 456", label: "mobile" }] }),
|
||||
c({ firstName: "Xavier", lastName: "Pons", emails: [{ value: "xavier.pons@gmail.com", label: "personal" }], phones: [], company: "Airbus", jobTitle: "Avionics Engineer" }),
|
||||
c({ firstName: "Yanis", lastName: "Bouzid", emails: [{ value: "yanis.bouzid@proton.me", label: "personal" }], phones: [{ value: "+33 6 76 89 01 23", label: "mobile" }] }),
|
||||
|
||||
69
lib/contacts/print-contacts.ts
Normal file
69
lib/contacts/print-contacts.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { fullContactDisplayName } from "./types"
|
||||
import type { FullContact } from "./types"
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
|
||||
export function printContacts(contacts: FullContact[], title = "Contacts"): void {
|
||||
const rows = contacts
|
||||
.map((c) => {
|
||||
const name = escapeHtml(
|
||||
fullContactDisplayName(c) || c.emails[0]?.value || c.phones[0]?.value || "—"
|
||||
)
|
||||
const email = escapeHtml(c.emails[0]?.value ?? "")
|
||||
const phone = escapeHtml(c.phones[0]?.value ?? "")
|
||||
const company = escapeHtml(
|
||||
[c.jobTitle, c.company].filter(Boolean).join(", ")
|
||||
)
|
||||
return `<tr><td>${name}</td><td>${email}</td><td>${phone}</td><td>${company}</td></tr>`
|
||||
})
|
||||
.join("")
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
body { font-family: Roboto, Arial, sans-serif; font-size: 12px; margin: 24px; color: #1f1f1f; }
|
||||
h1 { font-size: 20px; font-weight: normal; margin-bottom: 16px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #e0e0e0; }
|
||||
th { font-size: 11px; color: #5f6368; font-weight: 500; }
|
||||
@media print { body { margin: 12px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${escapeHtml(title)} (${contacts.length})</h1>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>E-mail</th>
|
||||
<th>Téléphone</th>
|
||||
<th>Fonction et entreprise</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const win = window.open("", "_blank", "noopener,noreferrer")
|
||||
if (!win) {
|
||||
window.alert("Impossible d'ouvrir la fenêtre d'impression. Vérifiez les pop-ups bloquées.")
|
||||
return
|
||||
}
|
||||
win.document.write(html)
|
||||
win.document.close()
|
||||
win.focus()
|
||||
win.onload = () => {
|
||||
win.print()
|
||||
}
|
||||
setTimeout(() => win.print(), 250)
|
||||
}
|
||||
@ -29,6 +29,8 @@ export interface FullContact {
|
||||
notes?: string
|
||||
labels?: string[]
|
||||
avatarUrl?: string
|
||||
interactionCount?: number
|
||||
isOtherContact?: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user