diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 77a3b31d..0b2cd344 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -98,10 +98,10 @@ export default function ContactsPage() { // Panel resize state - contact list const [listWidth, setListWidth] = useState(() => { - try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; } + try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 384; } catch { return 384; } }); const [isListResizing, setIsListResizing] = useState(false); - const listDragStartWidth = useRef(320); + const listDragStartWidth = useRef(384); // Check auth on mount useEffect(() => { @@ -172,21 +172,6 @@ export default function ContactsPage() { return getGroupMembers(activeCategory.groupId); }, [activeCategory, individuals, getGroupMembers]); - // Label for the current category - const categoryLabel = useMemo(() => { - if (activeCategory === "all") return t("tabs.all"); - if (activeCategory === "uncategorized") return t("no_category"); - if ("addressBookId" in activeCategory) { - const book = addressBooks.find(b => b.id === activeCategory.addressBookId); - return book?.name || t("tabs.all"); - } - if ("keyword" in activeCategory) { - return activeCategory.keyword; - } - const group = contacts.find(c => c.id === activeCategory.groupId); - return group ? getContactDisplayName(group) : t("tabs.all"); - }, [activeCategory, contacts, addressBooks, t]); - const handleSelectCategory = useCallback((category: ContactCategory) => { setActiveCategory(category); clearSelection(); @@ -306,6 +291,24 @@ export default function ContactsPage() { setView("bulk-add-to-group"); }, [clearSelection, toggleContactSelection, groups.length]); + const handleDuplicateContact = useCallback(async (source: ContactCard) => { + const { id: _id, created: _created, updated: _updated, ...rest } = source; + void _id; void _created; void _updated; + const data: Partial = JSON.parse(JSON.stringify(rest)); + if (supportsSync && client) { + await createContact(client, data); + toast.success(t("toast.created")); + } else { + const localContact: ContactCard = { + id: `local-${generateUUID()}`, + addressBookIds: data.addressBookIds || {}, + ...data, + }; + addLocalContact(localContact); + toast.success(t("toast.created")); + } + }, [supportsSync, client, createContact, addLocalContact, t]); + const handleSaveNew = useCallback(async (data: Partial) => { if (supportsSync && client) { await createContact(client, data); @@ -607,6 +610,16 @@ export default function ContactsPage() { contact={selectedContact} onEdit={handleEdit} onDelete={handleDelete} + onAddToGroup={ + selectedContact + ? () => handleAddContactToGroup(selectedContact.id) + : undefined + } + onDuplicate={ + selectedContact + ? () => void handleDuplicateContact(selectedContact) + : undefined + } isMobile={isMobile} /> ); @@ -702,7 +715,6 @@ export default function ContactsPage() { onSearchChange={setSearchQuery} onSelectContact={handleSelectContact} onCreateNew={handleCreateNew} - categoryLabel={categoryLabel} className="flex-1" selectedContactIds={selectedContactIds} onToggleSelection={toggleContactSelection} @@ -726,7 +738,7 @@ export default function ContactsPage() { setIsListResizing(false); localStorage.setItem("contacts-list-width", String(listWidth)); }} - onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }} + onDoubleClick={() => { setListWidth(384); localStorage.setItem("contacts-list-width", "384"); }} /> )} diff --git a/components/calendar/participant-input.tsx b/components/calendar/participant-input.tsx index 082b3898..7b004b92 100644 --- a/components/calendar/participant-input.tsx +++ b/components/calendar/participant-input.tsx @@ -90,13 +90,11 @@ export const ParticipantInput = forwardRef { - setTimeout(() => { - setShowSuggestions(false); - const trimmed = query.trim(); - if (trimmed && EMAIL_REGEX.test(trimmed)) { - addParticipant({ name: "", email: trimmed }); - } - }, 200); + const trimmed = query.trim(); + if (trimmed && EMAIL_REGEX.test(trimmed)) { + addParticipant({ name: "", email: trimmed }); + } + setTimeout(() => setShowSuggestions(false), 200); }, [query, addParticipant]); useImperativeHandle(ref, () => ({ @@ -167,7 +165,22 @@ export const ParticipantInput = forwardRef - {p.name || p.email} + {!disabled ? ( + + ) : ( + {p.name || p.email} + )} {!disabled && ( - )) +
+ {emails.map((email) => { + const sender = getEmailSender(email); + return ( + + ); + })} +
)} - + - +
{eventsLoading ? ( ) : eventsError ? ( -

{t("load_failed")}

+

{t("load_failed")}

) : !events || events.length === 0 ? ( -

{t("no_events")}

+

{t("no_events")}

) : ( - events.map((event) => ( - + ))} + - - )) + ))} + )} - - - ); -} - -function ActivitySection({ - icon: Icon, - title, - children, -}: { - icon: React.ComponentType<{ className?: string }>; - title: string; - children: React.ReactNode; -}) { - return ( -
-
- -

{title}

-
-
{children}
+
); } function LoadingRow() { return ( -
- +
+
); } diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx index 9e3c8012..19b032c9 100644 --- a/components/contacts/contact-context-menu.tsx +++ b/components/contacts/contact-context-menu.tsx @@ -11,16 +11,25 @@ import { Eye, Pencil, Mail, + Phone, ClipboardCopy, Download, Users, Trash2, + Copy, + Printer, } from "lucide-react"; import type { ContactCard } from "@/lib/jmap/types"; import { getContactPrimaryEmail } from "@/stores/contact-store"; import { exportContact } from "./contact-export"; +import { printContact } from "./contact-print"; import { toast } from "@/stores/toast-store"; +function getContactPrimaryPhone(contact: ContactCard): string { + if (!contact.phones) return ""; + return Object.values(contact.phones)[0]?.number || ""; +} + interface Position { x: number; y: number; @@ -38,6 +47,7 @@ interface ContactContextMenuProps { onEdit: () => void; onDelete: () => void; onAddToGroup: () => void; + onDuplicate?: () => void; onBatchExport?: () => void; onBatchAddToGroup?: () => void; onBatchDelete?: () => void; @@ -55,12 +65,14 @@ export function ContactContextMenu({ onEdit, onDelete, onAddToGroup, + onDuplicate, onBatchExport, onBatchAddToGroup, onBatchDelete, }: ContactContextMenuProps) { const t = useTranslations("contacts"); const email = getContactPrimaryEmail(contact); + const phone = getContactPrimaryPhone(contact); const showBatchActions = isMultiSelect && selectedCount > 1; const handle = (fn: () => void) => () => { @@ -73,10 +85,15 @@ export function ContactContextMenu({ window.location.href = `mailto:${email}`; }; - const handleCopyEmail = async () => { - if (!email) return; + const handleCall = () => { + if (!phone) return; + window.location.href = `tel:${phone}`; + }; + + const handleCopy = async (value: string) => { + if (!value) return; try { - await navigator.clipboard.writeText(email); + await navigator.clipboard.writeText(value); toast.success(t("detail.copied")); } catch { toast.error(t("detail.copy_failed")); @@ -88,6 +105,10 @@ export function ContactContextMenu({ toast.success(t("export.success", { count: 1 })); }; + const handlePrint = () => { + printContact(contact); + }; + if (showBatchActions) { return ( @@ -122,20 +143,34 @@ export function ContactContextMenu({ + {(email || phone) && } {email && ( - <> - - - - + + )} + {phone && ( + + )} + {email && ( + handleCopy(email))} + /> + )} + {phone && ( + handleCopy(phone))} + /> )} + {onDuplicate && ( + + )} + ; + label: string; + onClick: () => void; + destructive?: boolean; + separator?: false; + } + | { separator: true }; interface ContactDetailProps { contact: ContactCard | null; onEdit: () => void; onDelete: () => void; + onAddToGroup?: () => void; + onDuplicate?: () => void; isMobile?: boolean; className?: string; } @@ -27,9 +41,45 @@ function formatPhoneFeatures(features?: Record): string { 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 { - // 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 { @@ -41,20 +91,15 @@ function formatDate(dateInput: AnniversaryDate): string { 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)); + 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); - // 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; @@ -70,7 +115,7 @@ function formatDate(dateInput: AnniversaryDate): string { return dateStr; } -export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) { +export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, isMobile, className }: ContactDetailProps) { const t = useTranslations("contacts"); const smimeStore = useSmimeStore(); const [parsedCerts, setParsedCerts] = useState>(new Map()); @@ -88,7 +133,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } 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); @@ -97,7 +141,6 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } 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; @@ -128,6 +171,29 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } 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) : []; @@ -171,257 +237,259 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } 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 (
-
- +
+

{name || "-"}

{hasNickname && (

“{nicknames.map(n => n.name).join(", ")}”

)} - {titleLine && ( -

{titleLine}

- )} - {orgs.length > 0 && orgs[0].name && ( -

{orgs[0].name}

+ {subtitleParts.length > 0 && ( +

{subtitleParts.join(" · ")}

)}
-
+
+ {email && ( + + + {t("detail.compose_email")} + + )} + {phone && ( + + + {t("context_menu.call")} + + )} - +
-
-
- - - {/* Contact info */} - {emails.length > 0 && ( -
+
+ {hasContactDetails && ( +
+
{emails.map((e, i) => ( -
- - {e.address} - - {e.contexts && } - {e.label && ({e.label})} - + ))} -
- )} - {phones.length > 0 && ( -
{phones.map((p, i) => { - const featureStr = formatPhoneFeatures(p.features); + const features = formatPhoneFeatures(p.features); + const labelParts = [p.label, formatContexts(p.contexts), features].filter(Boolean) as string[]; return ( -
- - {p.number} - - {p.contexts && } - {featureStr && ( - {featureStr} - )} - -
+ + + ); })} -
- )} - {(roles.length > 0 || jobTitles.length > 1) && ( -
- {titles.map((tl, i) => ( -
- {tl.name} - {tl.kind && ( - {tl.kind} - )} -
- ))} -
- )} - - {orgs.length > 0 && ( -
- {orgs.map((o, i) => ( -
- {o.name} - {o.units && o.units.length > 0 && ( - - {o.units.map(u => u.name).join(", ")} - )} -
- ))} -
- )} - - {/* Addresses span full width */} - {addresses.length > 0 && ( -
-
-
- {addresses.map((a, i) => ( -
-
- {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 && } -
+ {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 ( + +
+ {lines.map((line, idx) => ( +
{line}
+ ))} {a.timeZone && (
{t("detail.timezone")}: {a.timeZone}
)}
- ))} -
-
-
- )} + + ); + })} - {onlineServices.length > 0 && ( -
{onlineServices.map((svc, i) => ( -
- {typeof svc.uri === 'string' && svc.uri.startsWith("http") ? ( - - {svc.user || svc.uri} - - ) : ( - {svc.user || String(svc.uri ?? '')} - )} - {svc.service && ( - {svc.service} - )} - {svc.contexts && } - -
+ +
+ {typeof svc.uri === 'string' && svc.uri.startsWith("http") ? ( + + {svc.user || svc.uri} + + ) : ( + {svc.user || String(svc.uri ?? '')} + )} + + + +
+
))} -
- )} +
+
+ )} - {anniversaries.length > 0 && ( -
- {anniversaries.map((ann, i) => ( -
- {formatDate(ann.date)} - - {t(`detail.anniversary_${ann.kind}`)} - -
+ {hasWork && ( +
+
+ {orgs.map((o, i) => ( + +
+ {o.name} + {o.units && o.units.length > 0 && ( + · {o.units.map(u => u.name).join(", ")} + )} +
+
))} -
- )} - - {personalInfo.length > 0 && ( -
- {personalInfo.map((pi, i) => ( -
- {pi.value} - {t(`detail.personal_${pi.kind}`)} - {pi.level && ( - ({pi.level}) - )} -
+ {titles.map((tl, i) => ( + +
{tl.name}
+
))} -
- )} +
+ + )} - {contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && ( -
-
- {contact.speakToAs.grammaticalGender && {t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}} - {contact.speakToAs.pronouns && (() => { - const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns; - return firstPronoun ? ( - {contact.speakToAs!.grammaticalGender ? " - " : ""}{firstPronoun} - ) : null; - })()} -
-
- )} - - {preferredLanguages.length > 0 && ( -
+ {hasPersonal && ( +
+
+ {anniversaries.map((ann, i) => { + const years = getCompletedYears(ann.date); + const suffixKey = ann.kind === "birth" ? "detail.age_years" : "detail.years_since"; + return ( + +
+ {formatDate(ann.date)} + {years !== null && ( + · {t(suffixKey, { count: years })} + )} +
+
+ ); + })} + {hasGender && ( + +
+ {contact.speakToAs?.grammaticalGender && ( + {t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })} + )} + {contact.speakToAs?.pronouns && (() => { + const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns; + return firstPronoun ? ( + {contact.speakToAs!.grammaticalGender ? " · " : ""}{firstPronoun} + ) : null; + })()} +
+
+ )} {preferredLanguages.map((lang, i) => ( -
- {lang.language} - {lang.contexts && } -
+ +
{lang.language}
+
))} -
- )} + {personalInfo.map((pi, i) => ( + +
{pi.value}
+
+ ))} +
+ + )} - {keywords.length > 0 && ( -
-
- {keywords.map((kw, i) => ( - - {kw} - - ))} -
-
- )} + {keywords.length > 0 && ( +
+
+ {keywords.map((kw, i) => ( + + {kw} + + ))} +
+
+ )} - {relatedTo.length > 0 && ( -
+ {relatedTo.length > 0 && ( +
+
{relatedTo.map(([uri, rel], i) => { const relType = rel.relation ? Object.keys(rel.relation).find(k => rel.relation![k]) : undefined; return ( -
- {uri} - {relType && ( - {relType} - )} -
+ +
{uri}
+
); })} -
- )} +
+ + )} - {cryptoKeys.length > 0 && ( -
+ {cryptoKeys.length > 0 && ( +
+
{cryptoKeys.map((key, i) => { const certInfo = parsedCerts.get(i); const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false; @@ -430,7 +498,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } : false; return ( -
+
{certInfo ? ( <>
@@ -451,12 +519,7 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } {certInfo.algorithm &&

{t("detail.cert_algorithm")}: {certInfo.algorithm}

}
{!alreadyImported && ( - @@ -466,7 +529,8 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } )} ) : ( -
- )} +
+ + )} - {(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && ( -
+ {(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && ( +
+
{contact.calendarUri && ( - + + + {contact.calendarUri} + + )} {contact.schedulingUri && ( -
- {t("detail.scheduling_uri")}: - {contact.schedulingUri} -
+ + + {contact.schedulingUri} + + )} {contact.freeBusyUri && ( -
- {t("detail.freebusy_uri")}: - {contact.freeBusyUri} -
+ + + {contact.freeBusyUri} + + )} -
- )} +
+ + )} - {/* Notes span full width */} - {notes.length > 0 && ( -
-
+ {notes.length > 0 && ( +
+
+ +
{notes.map((n, i) => ( -

{n.note}

+

{n.note}

))} -
+
- )} + + )} - {/* Timestamps span full width */} - {(contact.created || contact.updated) && ( -
- {contact.created &&
{t("detail.created")}: {formatDate(contact.created)}
} - {contact.updated &&
{t("detail.updated")}: {formatDate(contact.updated)}
} -
- )} + + + {(contact.created || contact.updated) && ( +
+ {contact.created &&
{t("detail.created")}: {formatDate(contact.created)}
} + {contact.updated &&
{t("detail.updated")}: {formatDate(contact.updated)}
} +
+ )} + + + ); +} + +function formatContexts(contexts?: Record): 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 ( +
+

{title}

+ {children} +
+ ); +} + +function FieldRow({ icon: Icon, label, children }: { icon: React.ComponentType<{ className?: string }>; label?: string; children: React.ReactNode }) { + return ( +
+ +
+ {label &&
{label}
} + {children} +
+
+ ); +} + +function RowActions({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) { + const [open, setOpen] = useState(false); + const ref = useRef(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 ( +
+ + {open && ( +
+ {items.map((item, i) => { + if (item.separator) { + return
; + } + return ( + + ); + })}
-
+ )}
); } -type SectionCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes"; - -const categoryStyles: Record = { - 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 ( -
-
- -

{title}

-
-
{children}
-
- ); -} - -function ContextBadge({ contexts }: { contexts: Record }) { - const labels = Object.keys(contexts).filter(k => contexts[k]); - if (labels.length === 0) return null; - - return ( - - {labels.join(", ")} - - ); -} - function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) { return ( - {open && ( -
+ {(open || !collapsible) && ( +
{children}
)} -
+ ); } +const MAX_PHOTO_DIM = 512; +const PHOTO_QUALITY = 0.85; + +async function processImageFile(file: File): Promise<{ uri: string; mediaType: string }> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const img = new Image(); + img.onload = () => { + const ratio = Math.min(1, MAX_PHOTO_DIM / Math.max(img.width, img.height)); + const w = Math.max(1, Math.round(img.width * ratio)); + const h = Math.max(1, Math.round(img.height * ratio)); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("canvas-unsupported")); + return; + } + ctx.drawImage(img, 0, 0, w, h); + const uri = canvas.toDataURL("image/jpeg", PHOTO_QUALITY); + resolve({ uri, mediaType: "image/jpeg" }); + }; + img.onerror = () => reject(new Error("invalid-image")); + img.src = reader.result as string; + }; + reader.onerror = () => reject(new Error("read-failed")); + reader.readAsDataURL(file); + }); +} + function Select({ value, onChange, children, className }: { value: string; onChange: (e: React.ChangeEvent) => void; @@ -313,10 +332,54 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc }, [contact]); const [selectedBookId, setSelectedBookId] = useState(currentBookId); + const initialPhotoEntry = useMemo(() => { + if (!contact?.media) return null; + for (const [key, m] of Object.entries(contact.media)) { + if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType }; + } + return null; + }, [contact]); + const [photoUri, setPhotoUri] = useState(initialPhotoEntry?.uri); + const [photoMediaType, setPhotoMediaType] = useState(initialPhotoEntry?.mediaType); + const [photoError, setPhotoError] = useState(null); + const [photoUploading, setPhotoUploading] = useState(false); + const photoInputRef = useRef(null); + const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const [emailErrors, setEmailErrors] = useState>({}); + const handlePhotoSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + if (!file.type.startsWith("image/")) { + setPhotoError(t("photo_invalid")); + return; + } + if (file.size > 10 * 1024 * 1024) { + setPhotoError(t("photo_too_large")); + return; + } + setPhotoError(null); + setPhotoUploading(true); + try { + const { uri, mediaType } = await processImageFile(file); + setPhotoUri(uri); + setPhotoMediaType(mediaType); + } catch { + setPhotoError(t("photo_invalid")); + } finally { + setPhotoUploading(false); + } + }; + + const handlePhotoRemove = () => { + setPhotoUri(undefined); + setPhotoMediaType(undefined); + setPhotoError(null); + }; + const validateEmail = (address: string): boolean => { if (!address.trim()) return true; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim()); @@ -427,6 +490,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc }); } + const mediaMap: Record = {}; + if (contact?.media) { + for (const [key, m] of Object.entries(contact.media)) { + if (m.kind !== "photo") mediaMap[key] = m; + } + } + if (photoUri) { + const photoKey = initialPhotoEntry?.key || "photo"; + mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType }; + } + const data: Partial = { name: { components: nameComponents, isOrdered: true }, nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined, @@ -453,6 +527,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc calendarUri: calendarUri.trim() || undefined, schedulingUri: schedulingUri.trim() || undefined, freeBusyUri: freeBusyUri.trim() || undefined, + media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined, ...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}), }; @@ -466,6 +541,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc } }; + const previewName = [givenName, surname].filter(Boolean).join(" ").trim(); + const previewEmail = emails.find(e => e.address.trim())?.address.trim() || ""; + return (
@@ -478,38 +556,82 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
-
+
{error && (
{error}
)} -
- - {/* Address Book Selector */} - {addressBooks && addressBooks.length > 1 && ( -
- - + +
+

{t("photo_hint")}

+ {photoError && ( +

{photoError}

+ )} + {photoUri && ( + + )}
+
+ +
+ + {addressBooks && addressBooks.length > 1 && ( + + + )} - {/* Name & Identity - full width */} -
- +
@@ -543,10 +665,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
-
{/* Email */} - +
{emails.map((entry, i) => (
@@ -598,7 +719,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc {/* Phone */} - +
{phones.map((entry, i) => (
@@ -656,7 +777,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc {/* Work & Organization */} - +
@@ -677,9 +798,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
- {/* Addresses - full width */} -
- + {/* Addresses */} + 0}>
{addresses.map((addr, i) => (
@@ -711,10 +831,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
-
{/* Online Services */} - + 0}>
{onlineServices.map((svc, i) => (
@@ -743,7 +862,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc {/* Anniversaries */} - + 0}>
{anniversaries.map((ann, i) => (
@@ -775,7 +894,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc {/* Personal Info */} - + 0}>
{personalInfoEntries.map((pi, i) => (
@@ -816,7 +935,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc {/* Categories */} - + {/* Gender */} - +
@@ -849,7 +968,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc {/* Calendar */} - +
@@ -866,9 +985,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCanc
- {/* Notes - full width */} -
- + {/* Notes */} +