import { useState } from 'react'; import { Building2, Mail, Phone, User, Pencil, Trash2, Check, X, Plus } from 'lucide-react'; import { useDavContacts, useSelectedCollection } from './useDavData'; import { useDavEdit, CREATING } from './useDavEdit'; import { useFilter, matches } from './useFilter'; import { useTranslation } from 'officerdev'; // Right panel of /contacts: the selected address book as cards, sorted by name. // // `hasPhoto` is a flag, not the bytes — the sidecar keeps embedded photos out of the list response so a // book with a hundred cards is not a hundred base64 blobs. Until there is an endpoint that serves one // card's photo, an initial stands in. const initials = (name: string) => name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((part) => part[0]?.toUpperCase() ?? '') .join('') || '?'; const FIELD = 'rounded-md border bg-background px-2 py-1 text-sm'; const blankDraft = () => ({ fullName: '', email: '', phone: '' }); export const ContactsView = () => { const { t } = useTranslation(); const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook'); const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null); const edit = useDavEdit(['caldav', 'contacts', selected?.path ?? null]); const [editing, setEditing] = useState(null); const [draft, setDraft] = useState({ fullName: '', email: '', phone: '' }); const [confirming, setConfirming] = useState(null); const [draftNew, setDraftNew] = useState | null>(null); const filter = useFilter('Filter contacts'); if (loadingCollections) return ; if (!selected) return ; // Everything a person might half-remember about someone: the name, either address, the company. const shown = contacts.filter((c) => matches(filter.query, c.fullName, c.org, c.title, ...c.emails, ...c.phones)); // Not an early return: the header carries the only way to ADD a card, and an empty address book is // exactly when somebody wants it. A filter that matches nothing says SO — otherwise it reads as an // address book that lost its contacts. const notice = error ? t('dav.readFailed', { name: selected.displayName, reason: String(error) }) : isLoading ? 'Loading contacts…' : contacts.length === 0 ? `${selected.displayName} has no contacts.` : shown.length === 0 ? `No contact matches “${filter.query}”.` : null; const sorted = [...shown].sort((a, b) => a.fullName.localeCompare(b.fullName)); const submitNew = (e: React.FormEvent) => { e.preventDefault(); if (!draftNew?.fullName.trim()) return; void edit .createContact(selected.path, { fullName: draftNew.fullName.trim(), emails: [draftNew.email].filter(Boolean), phones: [draftNew.phone].filter(Boolean), }) .then((ok) => ok && setDraftNew(null)); }; return (
{selected.displayName}
{filter.query ? `${shown.length} of ${contacts.length}` : contacts.length}{' '} {contacts.length === 1 ? 'contact' : 'contacts'}
{filter.input}
{/* Name, one email, one phone. A card can hold far more, and a CardDAV client can put it there — this is the form for adding somebody from their business card, not for modelling a person. */} {draftNew && (
setDraftNew({ ...draftNew, fullName: e.target.value })} placeholder="Name" className={FIELD} />
setDraftNew({ ...draftNew, email: e.target.value })} placeholder="Email" className={`${FIELD} min-w-0 flex-1 text-xs`} /> setDraftNew({ ...draftNew, phone: e.target.value })} placeholder="Phone" className={`${FIELD} min-w-0 flex-1 text-xs tabular-nums`} />
{edit.error && {edit.error}}
)} {notice && }
{sorted.map((contact) => (
{initials(contact.fullName)}
{contact.fullName || '(no name)'}
{(contact.title || contact.org) && (
{[contact.title, contact.org].filter(Boolean).join(' · ')}
)} {contact.emails.map((email) => ( {email} ))} {contact.phones.map((phone) => ( {phone} ))} {/* Name, one email, one phone — the three fields worth correcting from a list. A card can hold several of each; editing rewrites the FIRST and leaves the rest untouched, which is why `setProperty` replaces a line rather than rebuilding the card. */} {editing === contact.href && (
{ e.preventDefault(); if (!contact.href) return; /* The form shows the FIRST email and phone; a card can hold several. Sending just the one would delete the rest, so the others are carried through untouched. */ void edit .save(contact.href, { fullName: draft.fullName, emails: [draft.email, ...contact.emails.slice(1)].filter(Boolean), phones: [draft.phone, ...contact.phones.slice(1)].filter(Boolean), }) .then((ok) => ok && setEditing(null)); }} > setDraft((d) => ({ ...d, fullName: e.target.value }))} placeholder="Name" className="rounded-md border bg-background px-2 py-1 text-sm" /> setDraft((d) => ({ ...d, email: e.target.value }))} placeholder="Email" className="rounded-md border bg-background px-2 py-1 text-xs" /> setDraft((d) => ({ ...d, phone: e.target.value }))} placeholder="Phone" className="rounded-md border bg-background px-2 py-1 text-xs tabular-nums" />
)}
{contact.href && editing !== contact.href && (
{confirming === contact.href ? ( <> ) : ( <> )}
)}
))}
); }; const Placeholder = ({ text }: { text: string }) => (
{text}
);