583 lines
27 KiB
TypeScript
583 lines
27 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } 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 } from "@/stores/contact-store";
|
|
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";
|
|
|
|
interface ContactDetailProps {
|
|
contact: ContactCard | null;
|
|
onEdit: () => void;
|
|
onDelete: () => 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 formatDate(dateInput: AnniversaryDate): string {
|
|
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
|
|
// Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." }
|
|
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 year = pd.year;
|
|
const month = pd.month;
|
|
const day = pd.day;
|
|
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
const parts: string[] = [];
|
|
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
|
|
if (day) parts.push(String(day));
|
|
if (year) parts.push(String(year));
|
|
return parts.join(' ') || String(dateInput);
|
|
}
|
|
const dateStr = String(dateInput);
|
|
// Handle both ISO dates and partial dates like 1990-01-15 or --01-15
|
|
if (dateStr.startsWith("--")) {
|
|
// Partial date without year
|
|
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, 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:')) {
|
|
// data URI — extract base64 content
|
|
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')) {
|
|
// PEM-encoded certificate inline
|
|
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 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;
|
|
|
|
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">
|
|
<Avatar name={name} email={email} 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">“{nicknames.map(n => n.name).join(", ")}”</p>
|
|
)}
|
|
{titleLine && (
|
|
<p className="text-sm text-muted-foreground truncate">{titleLine}</p>
|
|
)}
|
|
{orgs.length > 0 && orgs[0].name && (
|
|
<p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
|
<Pencil className="w-4 h-4 mr-1" />
|
|
{t("form.edit_title")}
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation">
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-6 py-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
{/* Contact info */}
|
|
{emails.length > 0 && (
|
|
<Section icon={Mail} title={t("detail.emails")} category="contact">
|
|
{emails.map((e, i) => (
|
|
<div key={i} className="flex items-center gap-2 group">
|
|
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline">
|
|
{e.address}
|
|
</a>
|
|
{e.contexts && <ContextBadge contexts={e.contexts} />}
|
|
{e.label && <span className="text-xs text-muted-foreground">({e.label})</span>}
|
|
<div className={cn(
|
|
"flex items-center gap-0.5 transition-opacity",
|
|
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
|
)}>
|
|
<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")} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</Section>
|
|
)}
|
|
|
|
{phones.length > 0 && (
|
|
<Section icon={Phone} title={t("detail.phones")} category="contact">
|
|
{phones.map((p, i) => {
|
|
const featureStr = formatPhoneFeatures(p.features);
|
|
return (
|
|
<div key={i} className="flex items-center gap-2 group">
|
|
<a href={`tel:${p.number}`} className="text-sm text-primary hover:underline">
|
|
{p.number}
|
|
</a>
|
|
{p.contexts && <ContextBadge contexts={p.contexts} />}
|
|
{featureStr && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{featureStr}</span>
|
|
)}
|
|
<CopyButton
|
|
value={p.number}
|
|
label={t("detail.copy_phone")}
|
|
successMsg={t("detail.copied")}
|
|
failMsg={t("detail.copy_failed")}
|
|
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</Section>
|
|
)}
|
|
|
|
{(roles.length > 0 || jobTitles.length > 1) && (
|
|
<Section icon={Briefcase} title={t("detail.titles")} category="work">
|
|
{titles.map((tl, i) => (
|
|
<div key={i} className="text-sm flex items-center gap-2">
|
|
<span>{tl.name}</span>
|
|
{tl.kind && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{tl.kind}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</Section>
|
|
)}
|
|
|
|
{orgs.length > 0 && (
|
|
<Section icon={Building} title={t("detail.organizations")} category="work">
|
|
{orgs.map((o, i) => (
|
|
<div key={i} 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>
|
|
))}
|
|
</Section>
|
|
)}
|
|
|
|
{/* Addresses span full width */}
|
|
{addresses.length > 0 && (
|
|
<div className="md:col-span-2 xl:col-span-3">
|
|
<Section icon={MapPin} title={t("detail.addresses")} category="location">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
|
{addresses.map((a, i) => (
|
|
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
|
|
<div>
|
|
{a.full || a.fullAddress
|
|
? (a.full || a.fullAddress)
|
|
: a.components && a.components.length > 0
|
|
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
|
|
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
|
{a.contexts && <ContextBadge contexts={a.contexts} />}
|
|
</div>
|
|
{a.timeZone && (
|
|
<div className="text-xs text-muted-foreground">{t("detail.timezone")}: {a.timeZone}</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
</div>
|
|
)}
|
|
|
|
{onlineServices.length > 0 && (
|
|
<Section icon={Globe} title={t("detail.online_services")} category="digital">
|
|
{onlineServices.map((svc, i) => (
|
|
<div key={i} 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>
|
|
)}
|
|
{svc.service && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{svc.service}</span>
|
|
)}
|
|
{svc.contexts && <ContextBadge contexts={svc.contexts} />}
|
|
<CopyButton
|
|
value={svc.user || svc.uri}
|
|
label={t("detail.copy_url")}
|
|
successMsg={t("detail.copied")}
|
|
failMsg={t("detail.copy_failed")}
|
|
className={isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"}
|
|
/>
|
|
</div>
|
|
))}
|
|
</Section>
|
|
)}
|
|
|
|
{anniversaries.length > 0 && (
|
|
<Section icon={Cake} title={t("detail.anniversaries")} category="personal">
|
|
{anniversaries.map((ann, i) => (
|
|
<div key={i} className="flex items-center gap-2 text-sm">
|
|
<span>{formatDate(ann.date)}</span>
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
|
{t(`detail.anniversary_${ann.kind}`)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</Section>
|
|
)}
|
|
|
|
{personalInfo.length > 0 && (
|
|
<Section icon={Heart} title={t("detail.personal_info")} category="personal">
|
|
{personalInfo.map((pi, i) => (
|
|
<div key={i} className="flex items-center gap-2 text-sm">
|
|
<span>{pi.value}</span>
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{t(`detail.personal_${pi.kind}`)}</span>
|
|
{pi.level && (
|
|
<span className="text-xs text-muted-foreground">({pi.level})</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</Section>
|
|
)}
|
|
|
|
{contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && (
|
|
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
|
|
<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>
|
|
</Section>
|
|
)}
|
|
|
|
{preferredLanguages.length > 0 && (
|
|
<Section icon={Languages} title={t("detail.languages")} category="personal">
|
|
{preferredLanguages.map((lang, i) => (
|
|
<div key={i} className="flex items-center gap-2 text-sm">
|
|
<span>{lang.language}</span>
|
|
{lang.contexts && <ContextBadge contexts={lang.contexts} />}
|
|
</div>
|
|
))}
|
|
</Section>
|
|
)}
|
|
|
|
{keywords.length > 0 && (
|
|
<Section icon={Tag} title={t("detail.categories")} category="digital">
|
|
<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 icon={Users} title={t("detail.related_contacts")} category="personal">
|
|
{relatedTo.map(([uri, rel], i) => {
|
|
const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined;
|
|
return (
|
|
<div key={i} className="flex items-center gap-2 text-sm">
|
|
<span>{uri}</span>
|
|
{relType && (
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{relType}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</Section>
|
|
)}
|
|
|
|
{cryptoKeys.length > 0 && (
|
|
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital">
|
|
{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="p-3 rounded-lg border border-border 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="text-sm break-all">
|
|
{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>
|
|
);
|
|
})}
|
|
</Section>
|
|
)}
|
|
|
|
{(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && (
|
|
<Section icon={Calendar} title={t("detail.calendar")} category="calendar">
|
|
{contact.calendarUri && (
|
|
<div className="text-sm">
|
|
<span className="text-muted-foreground">{t("detail.calendar_uri")}: </span>
|
|
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.calendarUri}</a>
|
|
</div>
|
|
)}
|
|
{contact.schedulingUri && (
|
|
<div className="text-sm">
|
|
<span className="text-muted-foreground">{t("detail.scheduling_uri")}: </span>
|
|
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.schedulingUri}</a>
|
|
</div>
|
|
)}
|
|
{contact.freeBusyUri && (
|
|
<div className="text-sm">
|
|
<span className="text-muted-foreground">{t("detail.freebusy_uri")}: </span>
|
|
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">{contact.freeBusyUri}</a>
|
|
</div>
|
|
)}
|
|
</Section>
|
|
)}
|
|
|
|
{/* Notes span full width */}
|
|
{notes.length > 0 && (
|
|
<div className="md:col-span-2 xl:col-span-3">
|
|
<Section icon={StickyNote} title={t("detail.notes")} category="notes">
|
|
{notes.map((n, i) => (
|
|
<p key={i} className="text-sm whitespace-pre-wrap">{n.note}</p>
|
|
))}
|
|
</Section>
|
|
</div>
|
|
)}
|
|
|
|
{/* Timestamps span full width */}
|
|
{(contact.created || contact.updated) && (
|
|
<div className="md:col-span-2 xl:col-span-3 pt-2 border-t border-border 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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type SectionCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes";
|
|
|
|
const categoryStyles: Record<SectionCategory, string> = {
|
|
contact: "border-l-blue-400 dark:border-l-blue-500",
|
|
work: "border-l-amber-400 dark:border-l-amber-500",
|
|
location: "border-l-emerald-400 dark:border-l-emerald-500",
|
|
personal: "border-l-violet-400 dark:border-l-violet-500",
|
|
digital: "border-l-cyan-400 dark:border-l-cyan-500",
|
|
calendar: "border-l-rose-400 dark:border-l-rose-500",
|
|
notes: "border-l-stone-400 dark:border-l-stone-500",
|
|
};
|
|
|
|
function Section({ icon: Icon, title, children, category = "contact" }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode; category?: SectionCategory }) {
|
|
return (
|
|
<div className={cn("rounded-lg border border-border bg-card p-4 border-l-[3px]", categoryStyles[category])}>
|
|
<div className="flex items-center gap-2 mb-2.5">
|
|
<Icon className="w-4 h-4 text-muted-foreground" />
|
|
<h3 className="text-sm font-medium text-muted-foreground">{title}</h3>
|
|
</div>
|
|
<div className="space-y-1.5 pl-6">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ContextBadge({ contexts }: { contexts: Record<string, boolean> }) {
|
|
const labels = Object.keys(contexts).filter(k => contexts[k]);
|
|
if (labels.length === 0) return null;
|
|
|
|
return (
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground ml-1">
|
|
{labels.join(", ")}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 transition-opacity", className)}
|
|
title={label}
|
|
aria-label={label}
|
|
>
|
|
<Copy className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</button>
|
|
);
|
|
}
|