Files
SRCmail/components/contacts/contact-detail.tsx
T
Stefan HildebrandtandLinus Rath ab3e0e717a feat: email a contact or group via the in-app composer
Adds a "Send email to group" action (To / Cc / Bcc) that opens the composer
pre-filled with the group's members in the chosen field, preserving each
member's display name. It is available both in the group context menu (between
"Edit Group" and "Delete") and in the group detail panel's header (shown when
the group has at least one member with an email). The single-contact "Send
email" button in the contact detail panel uses the same path.

Routing is internal, not via mailto:. Contacts is its own route and the composer
lives in the mail route, so the handoff stashes the recipients
(savePendingMailto) and does a client-side router.push("/"); the main route's
existing consumePendingMailto effect opens the composer in the current account.
This avoids the OS mailto handler (which could open a different mail app) and
the protocol round-trip's full-page reload, which dropped the in-memory
per-account JMAP clients of a multi-account session (a logout).

- contacts/page.tsx: openComposeInApp(recipients, field) shared helper;
  handleComposeGroupFromSidebar (deduped "Name <email>" members, empty -> toast)
  and handleComposeContact; wired to the sidebar, group detail, contact detail.
- contact-group-detail.tsx: onComposeGroup(field) prop + To/Cc/Bcc header control
  (shown when the group has emailable members).
- contacts-sidebar.tsx: onComposeGroup(groupId, field) prop + "Send email to
  group" submenu between Edit and Delete.
- contact-detail.tsx: onCompose() prop; the button is no longer a mailto: link.
- mailto.ts: recipient splitter is quote-aware (reuses the composer's
  splitRecipients) so a comma in a display name survives — still useful for real
  OS mailto: links.
- i18n: contacts.groups.send_email{,_to,_cc,_bcc} and no_member_emails across all
  locales.

Display names round-trip via formatRecipient -> parseRecipientList.
2026-06-19 12:29:43 +02:00

731 lines
31 KiB
TypeScript

"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
import { ContactActivity } from "./contact-activity";
import { useSmimeStore } from "@/stores/smime-store";
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
import type { CertificateInfo } from "@/lib/smime/types";
import { toast } from "@/stores/toast-store";
import { exportContact } from "./contact-export";
import { printContact } from "./contact-print";
type MoreItem =
| {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
destructive?: boolean;
separator?: false;
}
| { separator: true };
interface ContactDetailProps {
contact: ContactCard | null;
onEdit: () => void;
onDelete: () => void;
onAddToGroup?: () => void;
onDuplicate?: () => void;
/** Compose an email to this contact in the app (no OS mailto handoff). */
onCompose?: () => void;
isMobile?: boolean;
className?: string;
}
function formatPhoneFeatures(features?: Record<string, boolean>): string {
if (!features) return "";
return Object.keys(features).filter(k => features[k]).join(", ");
}
function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
if (typeof dateInput === "object" && dateInput !== null) {
if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") {
const d = new Date(dateInput.utc);
if (!isNaN(d.getTime())) {
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
return {};
}
const pd = dateInput as PartialDate;
return { year: pd.year, month: pd.month, day: pd.day };
}
const s = String(dateInput);
if (s.startsWith("--")) {
const parts = s.substring(2).split("-");
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
}
const d = new Date(s);
if (!isNaN(d.getTime())) {
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
}
return {};
}
function getCompletedYears(dateInput: AnniversaryDate): number | null {
const { year, month, day } = getDateParts(dateInput);
if (!year) return null;
const now = new Date();
let years = now.getFullYear() - year;
const m = month ?? 1;
const d = day ?? 1;
const nowM = now.getMonth() + 1;
const nowD = now.getDate();
if (nowM < m || (nowM === m && nowD < d)) years -= 1;
if (years < 0) return null;
return years;
}
function formatDate(dateInput: AnniversaryDate): string {
if (typeof dateInput === 'object' && dateInput !== null) {
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
try {
const d = new Date(dateInput.utc as string);
if (!isNaN(d.getTime())) {
return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
}
} catch { /* fallback */ }
return String(dateInput.utc);
}
const pd = dateInput as PartialDate;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const parts: string[] = [];
if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]);
if (pd.day) parts.push(String(pd.day));
if (pd.year) parts.push(String(pd.year));
return parts.join(' ') || String(dateInput);
}
const dateStr = String(dateInput);
if (dateStr.startsWith("--")) {
const parts = dateStr.substring(2).split("-");
const month = parseInt(parts[0], 10);
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return day ? `${monthNames[month - 1]} ${day}` : monthNames[month - 1];
}
try {
const d = new Date(dateStr);
if (!isNaN(d.getTime())) {
return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
}
} catch { /* fallback */ }
return dateStr;
}
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
const smimeStore = useSmimeStore();
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
useEffect(() => {
if (!contact) return;
let cancelled = false;
const parseCerts = async () => {
const results = new Map<number, CertificateInfo>();
for (let i = 0; i < cryptoKeys.length; i++) {
const key = cryptoKeys[i];
if (typeof key.uri !== 'string') continue;
try {
let derBytes: ArrayBuffer | string | null = null;
if (key.uri.startsWith('data:')) {
const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) continue;
const b64 = key.uri.substring(commaIdx + 1);
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
derBytes = bytes.buffer;
} else if (key.uri.startsWith('-----BEGIN')) {
derBytes = key.uri;
}
if (!derBytes) continue;
const cert = parseCertificatePemOrDer(derBytes);
const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes;
const info = await extractCertificateInfo(cert, der);
if (!cancelled) results.set(i, info);
} catch { /* skip unparseable keys */ }
}
if (!cancelled) setParsedCerts(results);
};
if (cryptoKeys.length > 0) {
parseCerts();
} else {
setParsedCerts(new Map());
}
return () => { cancelled = true; };
}, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
if (!contact) {
return (
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
<BookUser className="w-16 h-16 mb-4 opacity-20" />
<p className="text-sm">{t("detail.no_contact_selected")}</p>
</div>
);
}
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const photoUri = getContactPhotoUri(contact);
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined;
const handleExport = () => {
exportContact(contact);
toast.success(t("export.success", { count: 1 }));
};
const handlePrint = () => {
printContact(contact, name);
};
const moreItems: MoreItem[] = [];
if (onAddToGroup) {
moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup });
}
if (onDuplicate) {
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
}
moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport });
moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint });
moreItems.push({ separator: true });
moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true });
const emails = contact.emails ? Object.values(contact.emails) : [];
const phones = contact.phones ? Object.values(contact.phones) : [];
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
const addresses = contact.addresses ? Object.values(contact.addresses) : [];
const notes = contact.notes ? Object.values(contact.notes) : [];
const titles = contact.titles ? Object.values(contact.titles) : [];
const jobTitles = titles.filter(t => t.kind !== "role");
const roles = titles.filter(t => t.kind === "role");
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
const handleImportContactCert = async (keyIndex: number) => {
const key = cryptoKeys[keyIndex];
if (!key?.uri || typeof key.uri !== 'string') return;
try {
let derBytes: ArrayBuffer | string;
if (key.uri.startsWith('data:')) {
const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) return;
const b64 = key.uri.substring(commaIdx + 1);
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
derBytes = bytes.buffer;
} else if (key.uri.startsWith('-----BEGIN')) {
derBytes = key.uri;
} else {
return;
}
await smimeStore.importPublicCert(derBytes, 'contact', contact.id);
toast.success(t("detail.cert_imported"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed"));
}
};
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
const nicknames = contact.nicknames ? Object.values(contact.nicknames) : [];
const hasNickname = nicknames.length > 0;
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
const hasWork = titles.length > 0 || orgs.length > 0;
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
const hasPersonal = anniversaries.length > 0 || personalInfo.length > 0 || hasGender || preferredLanguages.length > 0;
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4 min-w-0 flex-1">
<Avatar name={name} email={email} contactPhotoUri={photoUri} size={isMobile ? "md" : "lg"} />
<div className="min-w-0 flex-1">
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
{hasNickname && (
<p className="text-sm text-muted-foreground truncate">&ldquo;{nicknames.map(n => n.name).join(", ")}&rdquo;</p>
)}
{subtitleParts.length > 0 && (
<p className="text-sm text-muted-foreground truncate">{subtitleParts.join(" · ")}</p>
)}
</div>
</div>
<div className="flex gap-2 flex-shrink-0 flex-wrap">
{email && onCompose && (
<Button
variant="outline"
size="sm"
onClick={onCompose}
className="touch-manipulation"
>
<Send className="w-4 h-4 mr-1" />
{t("detail.compose_email")}
</Button>
)}
{phone && (
<a
href={`tel:${phone}`}
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
>
<Phone className="w-4 h-4 mr-1" />
{t("context_menu.call")}
</a>
)}
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" />
{t("form.edit_title")}
</Button>
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
</div>
</div>
</div>
<div className={cn("divide-y divide-border/60", isMobile ? "px-4" : "px-6")}>
{hasContactDetails && (
<Section title={t("detail.section_contact")}>
<div className="space-y-3">
{emails.map((e, i) => (
<FieldRow key={`em${i}`} icon={Mail} label={e.label || formatContexts(e.contexts) || t("detail.email_default_label")}>
<div className="flex items-center gap-2 group">
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline break-all">
{e.address}
</a>
<RowActions>
<a
href={`mailto:${e.address}`}
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.compose_email")}
aria-label={t("detail.compose_email")}
>
<Send className="w-3.5 h-3.5 text-muted-foreground" />
</a>
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</RowActions>
</div>
</FieldRow>
))}
{phones.map((p, i) => {
const features = formatPhoneFeatures(p.features);
const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[];
return (
<FieldRow key={`ph${i}`} icon={Phone} label={labelParts.length ? labelParts.join(" · ") : t("detail.phone_default_label")}>
<div className="flex items-center gap-2 group">
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
{p.number}
</a>
<RowActions>
<CopyButton value={p.number} label={t("detail.copy_phone")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</RowActions>
</div>
</FieldRow>
);
})}
{addresses.map((a, i) => {
const lines: string[] = [];
if (a.full || a.fullAddress) {
lines.push((a.full || a.fullAddress) as string);
} else if (a.components && a.components.length > 0) {
const joined = a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ");
if (joined) lines.push(joined);
} else {
const parts = [a.street, [a.postcode, a.locality].filter(Boolean).join(" "), a.region, a.country]
.map(s => (typeof s === "string" ? s.trim() : ""))
.filter(Boolean) as string[];
lines.push(...parts);
}
return (
<FieldRow key={`ad${i}`} icon={MapPin} label={formatContexts(a.contexts) || t("detail.address_default_label")}>
<div className="text-sm space-y-0.5">
{lines.map((line, idx) => (
<div key={idx}>{line}</div>
))}
{a.timeZone && (
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
)}
</div>
</FieldRow>
);
})}
{onlineServices.map((svc, i) => (
<FieldRow
key={`os${i}`}
icon={Globe}
label={[svc.service, formatContexts(svc.contexts)].filter(Boolean).join(" · ") || t("detail.online_service_default_label")}
>
<div className="flex items-center gap-2 group">
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{svc.user || svc.uri}
</a>
) : (
<span className="text-sm break-all">{svc.user || String(svc.uri ?? '')}</span>
)}
<RowActions>
<CopyButton value={svc.user || svc.uri} label={t("detail.copy_url")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
</RowActions>
</div>
</FieldRow>
))}
</div>
</Section>
)}
{hasWork && (
<Section title={t("detail.section_work")}>
<div className="space-y-3">
{orgs.map((o, i) => (
<FieldRow key={`org${i}`} icon={Building} label={t("detail.organization_label")}>
<div className="text-sm">
{o.name}
{o.units && o.units.length > 0 && (
<span className="text-muted-foreground"> · {o.units.map(u => u.name).join(", ")}</span>
)}
</div>
</FieldRow>
))}
{titles.map((tl, i) => (
<FieldRow
key={`tl${i}`}
icon={Briefcase}
label={tl.kind === "role" ? t("detail.role_label") : t("detail.title_label")}
>
<div className="text-sm">{tl.name}</div>
</FieldRow>
))}
</div>
</Section>
)}
{hasPersonal && (
<Section title={t("detail.section_personal")}>
<div className="space-y-3">
{anniversaries.map((ann, i) => {
const years = getCompletedYears(ann.date);
const suffixKey = ann.kind === "birth" ? "detail.age_years" : "detail.years_since";
return (
<FieldRow key={`an${i}`} icon={Cake} label={t(`detail.anniversary_${ann.kind}`)}>
<div className="text-sm">
{formatDate(ann.date)}
{years !== null && (
<span className="text-muted-foreground"> · {t(suffixKey, { count: years })}</span>
)}
</div>
</FieldRow>
);
})}
{hasGender && (
<FieldRow icon={UserCircle} label={t("detail.gender")}>
<div className="text-sm">
{contact.speakToAs?.grammaticalGender && (
<span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>
)}
{contact.speakToAs?.pronouns && (() => {
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
return firstPronoun ? (
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " · " : ""}{firstPronoun}</span>
) : null;
})()}
</div>
</FieldRow>
)}
{preferredLanguages.map((lang, i) => (
<FieldRow
key={`lg${i}`}
icon={Languages}
label={formatContexts(lang.contexts) || t("detail.language_label")}
>
<div className="text-sm">{lang.language}</div>
</FieldRow>
))}
{personalInfo.map((pi, i) => (
<FieldRow
key={`pi${i}`}
icon={Heart}
label={`${t(`detail.personal_${pi.kind}`)}${pi.level ? ` · ${pi.level}` : ""}`}
>
<div className="text-sm">{pi.value}</div>
</FieldRow>
))}
</div>
</Section>
)}
{keywords.length > 0 && (
<Section title={t("detail.categories")}>
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
{kw}
</span>
))}
</div>
</Section>
)}
{relatedTo.length > 0 && (
<Section title={t("detail.related_contacts")}>
<div className="space-y-2">
{relatedTo.map(([uri, rel], i) => {
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
return (
<FieldRow key={`rel${i}`} icon={Users} label={relType || t("detail.related_default_label")}>
<div className="text-sm break-all">{uri}</div>
</FieldRow>
);
})}
</div>
</Section>
)}
{cryptoKeys.length > 0 && (
<Section title={t("detail.crypto_keys")}>
<div className="space-y-3">
{cryptoKeys.map((key, i) => {
const certInfo = parsedCerts.get(i);
const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false;
const alreadyImported = certInfo?.emailAddresses?.[0]
? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0])
: false;
return (
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
{certInfo ? (
<>
<div className="flex items-center gap-2">
{isExpired ? (
<ShieldAlert className="w-4 h-4 text-destructive flex-shrink-0" />
) : (
<ShieldCheck className="w-4 h-4 text-primary flex-shrink-0" />
)}
<span className="text-sm font-medium truncate">{certInfo.subject}</span>
</div>
<div className="text-xs text-muted-foreground space-y-0.5 pl-6">
<p>{t("detail.cert_issuer")}: {certInfo.issuer}</p>
<p>
{t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()}
{isExpired && <span className="text-destructive ml-1">({t("detail.cert_expired")})</span>}
</p>
<p>{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...</p>
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
</div>
{!alreadyImported && (
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
<Download className="w-3 h-3 mr-1" />
{t("detail.import_to_smime")}
</Button>
)}
{alreadyImported && (
<p className="text-xs text-green-600 pl-6 mt-1">{t("detail.cert_already_imported")}</p>
)}
</>
) : (
<div className="flex items-start gap-2 text-sm break-all">
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri}
</a>
) : (
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
)}
</div>
)}
</div>
);
})}
</div>
</Section>
)}
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
<Section title={t("detail.calendar")}>
<div className="space-y-3">
{contact.calendarUri && (
<FieldRow icon={Calendar} label={t("detail.calendar_uri")}>
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{contact.calendarUri}
</a>
</FieldRow>
)}
{contact.schedulingUri && (
<FieldRow icon={Calendar} label={t("detail.scheduling_uri")}>
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{contact.schedulingUri}
</a>
</FieldRow>
)}
{contact.freeBusyUri && (
<FieldRow icon={Calendar} label={t("detail.freebusy_uri")}>
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
{contact.freeBusyUri}
</a>
</FieldRow>
)}
</div>
</Section>
)}
{notes.length > 0 && (
<Section title={t("detail.notes")}>
<div className="flex items-start gap-3">
<StickyNote className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
<div className="text-sm space-y-2 flex-1 min-w-0">
{notes.map((n, i) => (
<p key={i} className="whitespace-pre-wrap">{n.note}</p>
))}
</div>
</div>
</Section>
)}
<ContactActivity contact={contact} />
{(contact.created || contact.updated) && (
<div className="py-4 text-xs text-muted-foreground space-y-1">
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
</div>
)}
</div>
</div>
);
}
function formatContexts(contexts?: Record<string, boolean>): string {
if (!contexts) return "";
return Object.keys(contexts).filter(k => contexts[k]).join(", ");
}
export function Section({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
return (
<section className={cn("py-6", className)}>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">{title}</h3>
{children}
</section>
);
}
function FieldRow({ icon: Icon, label, children }: { icon: React.ComponentType<{ className?: string }>; label?: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3">
<Icon className="w-4 h-4 text-muted-foreground mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0">
{label && <div className="text-xs text-muted-foreground mb-0.5">{label}</div>}
{children}
</div>
</div>
);
}
function RowActions({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{children}
</div>
);
}
function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDocMouseDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDocMouseDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocMouseDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
if (items.length === 0) return null;
return (
<div ref={ref} className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setOpen((o) => !o)}
title={label}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
className="touch-manipulation"
>
<MoreHorizontal className="w-4 h-4" />
</Button>
{open && (
<div
role="menu"
className="absolute right-0 top-full mt-1 z-30 min-w-[200px] rounded-md border border-border bg-popover text-popover-foreground shadow-lg py-1 animate-in fade-in-0 zoom-in-95 duration-100"
>
{items.map((item, i) => {
if (item.separator) {
return <div key={i} role="separator" className="my-1 h-px bg-border" />;
}
return (
<button
key={i}
type="button"
role="menuitem"
onClick={() => {
item.onClick();
setOpen(false);
}}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
)}
>
<item.icon className={cn("w-4 h-4 flex-shrink-0", item.destructive ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} />
<span className="flex-1">{item.label}</span>
</button>
);
})}
</div>
)}
</div>
);
}
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
return (
<button
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
toast.success(successMsg);
} catch {
toast.error(failMsg);
}
}}
className={cn("p-1.5 rounded hover:bg-muted transition-colors touch-manipulation", className)}
title={label}
aria-label={label}
>
<Copy className="w-3.5 h-3.5 text-muted-foreground" />
</button>
);
}