diff --git a/.env.example b/.env.example index 69564160..34e78622 100644 --- a/.env.example +++ b/.env.example @@ -1,70 +1,75 @@ -# JMAP Webmail Configuration -# Copy this file to .env.local and fill in your values +# JMAP Webmail — Production Configuration +# Copy this file to .env.local and fill in your values. +# For development with the built-in mock server, see .env.dev.example instead. # ============================================================================= -# Runtime Configuration (recommended for Docker) -# These are read at request time, allowing post-build configuration +# JMAP Server (required) # ============================================================================= # App name displayed in the UI APP_NAME=JMAP Webmail -# JMAP server URL (required) -# This is the URL of your JMAP-compatible mail server +# URL of your JMAP-compatible mail server (required) JMAP_SERVER_URL=https://your-jmap-server.com # ============================================================================= -# Build-time Configuration (legacy, still supported as fallback) -# These are baked into the bundle at build time +# Stalwart Mail Server Integration # ============================================================================= -# NEXT_PUBLIC_APP_NAME=JMAP Webmail -# NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com +# Enable Stalwart-specific features (password change, sieve filters, etc.) +# Set to "false" to disable if using a non-Stalwart JMAP server. +# STALWART_FEATURES=true # ============================================================================= -# Logging Configuration +# OAuth / OpenID Connect (optional) # ============================================================================= -# Log format: "text" (colored, human-readable) or "json" (structured, for log aggregation) -LOG_FORMAT=text +# Set to "true" to use OAuth instead of basic JMAP authentication +# OAUTH_ENABLED=true -# Log level: "error", "warn", "info", or "debug" -LOG_LEVEL=info +# OAuth client ID registered with your identity provider +# OAUTH_CLIENT_ID=your-client-id -# ============================================================================= -# Docker Configuration -# ============================================================================= -# When running with Docker, set these in .env.local: -# APP_NAME=My Webmail -# JMAP_SERVER_URL=https://mail.example.com -# -# Then run: -# docker compose up -d +# OAuth client secret (server-side only, never exposed to the browser) +# OAUTH_CLIENT_SECRET=your-client-secret + +# OpenID Connect issuer URL for discovery +# OAUTH_ISSUER_URL=https://your-idp.example.com # ============================================================================= # Session & Security # ============================================================================= -# Secret key for encrypting "Remember me" sessions and settings sync data -# Required for both "Remember me" and settings sync features -# Use a strong random string (e.g. openssl rand -base64 32) +# Secret key for encrypting "Remember me" sessions and settings sync data. +# Required for both "Remember me" and settings sync features. +# Generate with: openssl rand -base64 32 # SESSION_SECRET=your-secret-key-here # ============================================================================= # Settings Sync # ============================================================================= -# Enable server-side settings persistence (requires SESSION_SECRET) +# Enable server-side settings persistence (requires SESSION_SECRET). # When enabled, user settings are encrypted and stored on the server, # allowing them to sync across browsers and devices. # SETTINGS_SYNC_ENABLED=true -# Directory for storing encrypted settings files (default: ./data/settings) +# Directory for storing encrypted settings files (default: ./data/settings). # For Docker, mount a persistent volume at this path. # SETTINGS_DATA_DIR=./data/settings # ============================================================================= -# Login Page Customization +# Logging +# ============================================================================= + +# Log format: "text" (colored, human-readable) or "json" (structured, for log aggregation) +# LOG_FORMAT=text + +# Log level: "error", "warn", "info", or "debug" +# LOG_LEVEL=info + +# ============================================================================= +# Login Page Customization (all optional) # ============================================================================= # Company or organization name displayed above the version on the login page @@ -78,3 +83,12 @@ LOG_LEVEL=info # URL for the company website link on the login page # LOGIN_WEBSITE_URL=https://example.com + +# ============================================================================= +# Legacy Build-time Variables (still supported as fallback) +# ============================================================================= +# These are baked into the bundle at build time. The runtime variables above +# take priority when both are set. +# +# NEXT_PUBLIC_APP_NAME=JMAP Webmail +# NEXT_PUBLIC_JMAP_SERVER_URL=https://your-jmap-server.com diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 2e2f2b5a..a3e94e69 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -38,6 +38,46 @@ function isValidDomain(domain: string): boolean { return true; } +// Known multi-part TLDs where the registrable domain includes one extra label. +const MULTI_PART_TLDS = new Set([ + "co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk", + "co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp", + "co.kr", "or.kr", "go.kr", "ac.kr", + "co.in", "net.in", "org.in", "ac.in", "gov.in", + "co.nz", "org.nz", "net.nz", "govt.nz", "ac.nz", + "co.za", "org.za", "net.za", "gov.za", "ac.za", + "com.au", "net.au", "org.au", "edu.au", "gov.au", + "com.br", "net.br", "org.br", "edu.br", "gov.br", + "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", + "com.mx", "net.mx", "org.mx", "gob.mx", "edu.mx", + "com.ar", "net.ar", "org.ar", "gob.ar", "edu.ar", + "com.tw", "net.tw", "org.tw", "edu.tw", "gov.tw", + "com.hk", "net.hk", "org.hk", "edu.hk", "gov.hk", + "com.sg", "net.sg", "org.sg", "edu.sg", "gov.sg", + "com.my", "net.my", "org.my", "edu.my", "gov.my", + "com.ph", "net.ph", "org.ph", "edu.ph", "gov.ph", + "com.pk", "net.pk", "org.pk", "edu.pk", "gov.pk", + "com.ng", "net.ng", "org.ng", "edu.ng", "gov.ng", + "co.il", "org.il", "net.il", "ac.il", "gov.il", + "co.th", "or.th", "ac.th", "go.th", "in.th", + "co.id", "or.id", "ac.id", "go.id", "web.id", + "com.tr", "net.tr", "org.tr", "edu.tr", "gov.tr", + "com.ua", "net.ua", "org.ua", "edu.ua", "gov.ua", + "com.eg", "net.eg", "org.eg", "edu.eg", "gov.eg", + "com.sa", "net.sa", "org.sa", "edu.sa", "gov.sa", + "co.ke", "or.ke", "ac.ke", "go.ke", "ne.ke", +]); + +function getRootDomain(domain: string): string { + const parts = domain.split("."); + if (parts.length <= 2) return domain; + const lastTwo = parts.slice(-2).join("."); + if (MULTI_PART_TLDS.has(lastTwo)) { + return parts.length >= 3 ? parts.slice(-3).join(".") : domain; + } + return parts.slice(-2).join("."); +} + function evictOldest() { if (cache.size < CACHE_MAX_SIZE) return; // Evict the oldest entry @@ -62,7 +102,8 @@ export async function GET(request: NextRequest) { }); } - const normalizedDomain = domain.toLowerCase(); + // Resolve to root domain so subdomains share the same favicon lookup + const normalizedDomain = getRootDomain(domain.toLowerCase()); // Check negative cache (domains known to have no favicon) const neg = negativeCache.get(normalizedDomain); diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 550c1294..0dfe7739 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; -import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send } from "lucide-react"; +import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle } from "lucide-react"; import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -17,6 +17,30 @@ interface ContactDetailProps { className?: string; } +function formatPhoneFeatures(features?: Record): string { + if (!features) return ""; + return Object.keys(features).filter(k => features[k]).join(", "); +} + +function formatDate(dateStr: string): string { + // 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"); @@ -36,6 +60,20 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } 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 cryptoKeys = contact.cryptoKeys ? Object.values(contact.cryptoKeys) : []; + 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 (
@@ -45,6 +83,12 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }

{name || "—"}

+ {hasNickname && ( +

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

+ )} + {titleLine && ( +

{titleLine}

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

{orgs[0].name}

)} @@ -62,129 +106,299 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
-
- {emails.length > 0 && ( -
- {emails.map((e, i) => ( -
- - {e.address} - - {e.contexts && ( - - )} -
- - +
+
+ {/* Contact info */} + {emails.length > 0 && ( +
+ {emails.map((e, i) => ( +
+ + {e.address} - -
-
- ))} -
- )} - - {phones.length > 0 && ( -
- {phones.map((p, i) => ( -
- - {p.number} - - {p.contexts && ( - - )} -
+ ))} +
+ )} + + {phones.length > 0 && ( +
+ {phones.map((p, i) => { + const featureStr = formatPhoneFeatures(p.features); + return ( +
+ + {p.number} + + {p.contexts && } + {featureStr && ( + {featureStr} + )} + +
+ ); + })} +
+ )} + + {(roles.length > 0 || jobTitles.length > 1) && ( +
+ {titles.map((tl, i) => ( +
+ {tl.name} + {tl.kind && ( + {tl.kind} )} - title={t("detail.copy_phone")} - aria-label={t("detail.copy_phone")} - > - - -
- ))} -
- )} +
+ ))} + + )} - {orgs.length > 0 && ( -
- {orgs.map((o, i) => ( -
- {o.name} - {o.units && o.units.length > 0 && ( - — {o.units.map(u => u.name).join(", ")} + {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.fullAddress + ? a.fullAddress + : [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")} + {a.contexts && } +
+ {a.timeZone && ( +
{t("detail.timezone")}: {a.timeZone}
+ )} +
+ ))} +
+
+
+ )} + + {onlineServices.length > 0 && ( +
+ {onlineServices.map((svc, i) => ( +
+ {svc.uri.startsWith("http") ? ( + + {svc.user || svc.uri} + + ) : ( + {svc.user || svc.uri} + )} + {svc.service && ( + {svc.service} + )} + {svc.contexts && } + +
+ ))} +
+ )} + + {anniversaries.length > 0 && ( +
+ {anniversaries.map((ann, i) => ( +
+ {formatDate(ann.date)} + + {t(`detail.anniversary_${ann.kind}`)} + +
+ ))} +
+ )} + + {personalInfo.length > 0 && ( +
+ {personalInfo.map((pi, i) => ( +
+ {pi.value} + {t(`detail.personal_${pi.kind}`)} + {pi.level && ( + ({pi.level}) + )} +
+ ))} +
+ )} + + {contact.gender && (contact.gender.sex || contact.gender.identity) && ( +
+
+ {contact.gender.sex && {t(`detail.gender_${contact.gender.sex.toUpperCase()}`, { defaultValue: contact.gender.sex })}} + {contact.gender.identity && ( + {contact.gender.sex ? " — " : ""}{contact.gender.identity} )}
- ))} -
- )} +
+ )} - {addresses.length > 0 && ( -
- {addresses.map((a, i) => ( -
- {[a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")} - {a.contexts && ( - - )} + {preferredLanguages.length > 0 && ( +
+ {preferredLanguages.map((lang, i) => ( +
+ {lang.language} + {lang.contexts && } +
+ ))} +
+ )} + + {keywords.length > 0 && ( +
+
+ {keywords.map((kw, i) => ( + + {kw} + + ))}
- ))} -
- )} +
+ )} - {notes.length > 0 && ( -
- {notes.map((n, i) => ( -

{n.note}

- ))} -
- )} + {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} + )} +
+ ); + })} +
+ )} + + {cryptoKeys.length > 0 && ( +
+ {cryptoKeys.map((key, i) => ( +
+ {key.uri.startsWith("http") ? ( + + {key.uri} + + ) : ( + {key.uri.substring(0, 80)}{key.uri.length > 80 ? "…" : ""} + )} +
+ ))} +
+ )} + + {(contact.calendarUri || contact.schedulingUri || contact.freeBusyUri) && ( +
+ {contact.calendarUri && ( +
+ {t("detail.calendar_uri")}: + {contact.calendarUri} +
+ )} + {contact.schedulingUri && ( +
+ {t("detail.scheduling_uri")}: + {contact.schedulingUri} +
+ )} + {contact.freeBusyUri && ( +
+ {t("detail.freebusy_uri")}: + {contact.freeBusyUri} +
+ )} +
+ )} + + {/* Notes span full width */} + {notes.length > 0 && ( +
+
+ {notes.map((n, i) => ( +

{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)}
} +
+ )} + ); } -function Section({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) { +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}
+
{children}
); } @@ -194,8 +408,28 @@ function ContextBadge({ contexts }: { contexts: Record }) { 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 ( + + ); +} diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 3ab19a9c..657be3a2 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -2,11 +2,11 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; -import { X, Plus } from "lucide-react"; +import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; -import type { ContactCard } from "@/lib/jmap/types"; +import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo } from "@/lib/jmap/types"; interface EmailEntry { address: string; @@ -16,6 +16,33 @@ interface EmailEntry { interface PhoneEntry { number: string; context: "work" | "private" | ""; + feature: "voice" | "cell" | "fax" | "pager" | "video" | "text" | ""; +} + +interface OnlineServiceEntry { + uri: string; + service: string; + label: string; +} + +interface AnniversaryEntry { + date: string; + kind: "birth" | "death" | "wedding" | "other"; +} + +interface PersonalInfoEntry { + value: string; + kind: "expertise" | "hobby" | "interest" | "other"; + level: "high" | "medium" | "low" | ""; +} + +interface AddressEntry { + street: string; + locality: string; + region: string; + postcode: string; + country: string; + context: "work" | "private" | ""; } interface ContactFormProps { @@ -24,15 +51,92 @@ interface ContactFormProps { onCancel: () => void; } +type FormCategory = "contact" | "work" | "location" | "personal" | "digital" | "calendar" | "notes"; + +const formCategoryStyles: 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 FormSection({ icon: Icon, title, children, collapsible, defaultOpen = false, category = "contact" }: { + icon: React.ComponentType<{ className?: string }>; + title: string; + children: React.ReactNode; + collapsible?: boolean; + defaultOpen?: boolean; + category?: FormCategory; +}) { + const [open, setOpen] = useState(defaultOpen || !collapsible); + + return ( +
+ + {open && ( +
+ {children} +
+ )} +
+ ); +} + +function Select({ value, onChange, children, className }: { + value: string; + onChange: (e: React.ChangeEvent) => void; + children: React.ReactNode; + className?: string; +}) { + return ( + + ); +} + export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) { const t = useTranslations("contacts.form"); const isEditing = !!contact; - const givenInit = contact?.name?.components?.find(c => c.kind === "given")?.value || ""; - const surnameInit = contact?.name?.components?.find(c => c.kind === "surname")?.value || ""; + const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || ""; - const [givenName, setGivenName] = useState(givenInit); - const [surname, setSurname] = useState(surnameInit); + const [prefix, setPrefix] = useState(findComponent("prefix")); + const [givenName, setGivenName] = useState(findComponent("given")); + const [additionalName, setAdditionalName] = useState(findComponent("additional")); + const [surname, setSurname] = useState(findComponent("surname")); + const [suffix, setSuffix] = useState(findComponent("suffix")); + + const [nickname, setNickname] = useState( + contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : "" + ); const [emails, setEmails] = useState(() => { if (contact?.emails) { @@ -49,6 +153,7 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) { return Object.values(contact.phones).map(p => ({ number: p.number, context: p.contexts?.work ? "work" : p.contexts?.private ? "private" : "", + feature: p.features?.cell ? "cell" : p.features?.fax ? "fax" : p.features?.pager ? "pager" : p.features?.video ? "video" : p.features?.text ? "text" : p.features?.voice ? "voice" : "", })); } return []; @@ -57,11 +162,85 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) { const [organization, setOrganization] = useState( contact?.organizations ? Object.values(contact.organizations)[0]?.name || "" : "" ); + const [department, setDepartment] = useState( + contact?.organizations ? (Object.values(contact.organizations)[0]?.units?.[0]?.name || "") : "" + ); + + const [jobTitle, setJobTitle] = useState(() => { + if (contact?.titles) { + const t = Object.values(contact.titles).find(t => t.kind !== "role"); + return t?.name || ""; + } + return ""; + }); + const [role, setRole] = useState(() => { + if (contact?.titles) { + const r = Object.values(contact.titles).find(t => t.kind === "role"); + return r?.name || ""; + } + return ""; + }); + + const [addresses, setAddresses] = useState(() => { + if (contact?.addresses) { + return Object.values(contact.addresses).map(a => ({ + street: a.street || "", + locality: a.locality || "", + region: a.region || "", + postcode: a.postcode || "", + country: a.country || "", + context: a.contexts?.work ? "work" : a.contexts?.private ? "private" : "", + })); + } + return []; + }); + + const [onlineServices, setOnlineServices] = useState(() => { + if (contact?.onlineServices) { + return Object.values(contact.onlineServices).map(s => ({ + uri: s.uri, + service: s.service || "", + label: s.label || "", + })); + } + return []; + }); + + const [anniversaries, setAnniversaries] = useState(() => { + if (contact?.anniversaries) { + return Object.values(contact.anniversaries).map(a => ({ + date: a.date, + kind: a.kind, + })); + } + return []; + }); + + const [personalInfoEntries, setPersonalInfoEntries] = useState(() => { + if (contact?.personalInfo) { + return Object.values(contact.personalInfo).map(p => ({ + value: p.value, + kind: p.kind, + level: p.level || "", + })); + } + return []; + }); + + const [keywordsStr, setKeywordsStr] = useState( + contact?.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]).join(", ") : "" + ); const [note, setNote] = useState( contact?.notes ? Object.values(contact.notes)[0]?.note || "" : "" ); + const [genderSex, setGenderSex] = useState(contact?.gender?.sex || ""); + const [genderIdentity, setGenderIdentity] = useState(contact?.gender?.identity || ""); + const [calendarUri, setCalendarUri] = useState(contact?.calendarUri || ""); + const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || ""); + const [freeBusyUri, setFreeBusyUri] = useState(contact?.freeBusyUri || ""); + const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const [emailErrors, setEmailErrors] = useState>({}); @@ -110,33 +289,94 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) { }); const validPhones = phones.filter(p => p.number.trim()); - const phonesMap: Record }> = {}; + const phonesMap: Record; features?: Record }> = {}; validPhones.forEach((entry, i) => { - const obj: { number: string; contexts?: Record } = { number: entry.number.trim() }; + const obj: { number: string; contexts?: Record; features?: Record } = { number: entry.number.trim() }; if (entry.context) { obj.contexts = { [entry.context]: true }; } + if (entry.feature) { + obj.features = { [entry.feature]: true }; + } phonesMap[`p${i}`] = obj; }); const nameComponents = []; - if (givenName.trim()) { - nameComponents.push({ kind: "given" as const, value: givenName.trim() }); - } - if (surname.trim()) { - nameComponents.push({ kind: "surname" as const, value: surname.trim() }); + if (prefix.trim()) nameComponents.push({ kind: "prefix" as const, value: prefix.trim() }); + if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() }); + if (additionalName.trim()) nameComponents.push({ kind: "additional" as const, value: additionalName.trim() }); + if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() }); + if (suffix.trim()) nameComponents.push({ kind: "suffix" as const, value: suffix.trim() }); + + const titlesMap: Record = {}; + if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" }; + if (role.trim()) titlesMap["t1"] = { name: role.trim(), kind: "role" }; + + const orgUnits = department.trim() ? [{ name: department.trim() }] : undefined; + + const addressesMap: Record ? V : never> = {}; + addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => { + const obj: Record = {}; + if (a.street.trim()) obj.street = a.street.trim(); + if (a.locality.trim()) obj.locality = a.locality.trim(); + if (a.region.trim()) obj.region = a.region.trim(); + if (a.postcode.trim()) obj.postcode = a.postcode.trim(); + if (a.country.trim()) obj.country = a.country.trim(); + if (a.context) obj.contexts = { [a.context]: true }; + // @ts-expect-error - dynamic build + addressesMap[`a${i}`] = obj; + }); + + const onlineServicesMap: Record = {}; + onlineServices.filter(s => s.uri.trim()).forEach((s, i) => { + const obj: ContactOnlineService = { uri: s.uri.trim() }; + if (s.service.trim()) obj.service = s.service.trim(); + if (s.label.trim()) obj.label = s.label.trim(); + onlineServicesMap[`os${i}`] = obj; + }); + + const anniversariesMap: Record = {}; + anniversaries.filter(a => a.date.trim()).forEach((a, i) => { + anniversariesMap[`an${i}`] = { date: a.date.trim(), kind: a.kind }; + }); + + const personalInfoMap: Record = {}; + personalInfoEntries.filter(p => p.value.trim()).forEach((p, i) => { + const obj: ContactPersonalInfo = { value: p.value.trim(), kind: p.kind }; + if (p.level) obj.level = p.level as "high" | "medium" | "low"; + personalInfoMap[`pi${i}`] = obj; + }); + + const keywordsMap: Record = {}; + if (keywordsStr.trim()) { + keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => { + keywordsMap[k] = true; + }); } const data: Partial = { name: { components: nameComponents, isOrdered: true }, + nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined, emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined, phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined, + titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined, organizations: organization.trim() - ? { o0: { name: organization.trim() } } + ? { o0: { name: organization.trim(), units: orgUnits } } : undefined, + addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined, + onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined, + anniversaries: Object.keys(anniversariesMap).length > 0 ? anniversariesMap : undefined, + personalInfo: Object.keys(personalInfoMap).length > 0 ? personalInfoMap : undefined, + keywords: Object.keys(keywordsMap).length > 0 ? keywordsMap : undefined, notes: note.trim() ? { n0: { note: note.trim() } } : undefined, + gender: (genderSex.trim() || genderIdentity.trim()) + ? { sex: genderSex.trim() || undefined, identity: genderIdentity.trim() || undefined } + : undefined, + calendarUri: calendarUri.trim() || undefined, + schedulingUri: schedulingUri.trim() || undefined, + freeBusyUri: freeBusyUri.trim() || undefined, }; setIsSaving(true); @@ -150,186 +390,402 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) { }; return ( -
-
+ +

{isEditing ? t("edit_title") : t("create_title")}

+
-
- {error && ( -
- {error} -
- )} +
+
+ {error && ( +
+ {error} +
+ )} -
-
- - setGivenName(e.target.value)} - placeholder={t("given_name")} - autoFocus - /> -
-
- - setSurname(e.target.value)} - placeholder={t("surname")} - /> -
-
+
-
- -
- {emails.map((entry, i) => ( -
-
- { - const next = [...emails]; - next[i] = { ...next[i], address: e.target.value }; - setEmails(next); - if (emailErrors[i]) { - setEmailErrors(prev => { - const n = { ...prev }; - delete n[i]; - return n; - }); - } - }} - onBlur={() => handleEmailBlur(i, entry.address)} - placeholder={t("email_placeholder")} - className={cn("flex-1", emailErrors[i] && "border-red-500 focus:ring-red-500")} - /> - - {emails.length > 1 && ( - + )} +
+ {emailErrors[i] && ( +

{emailErrors[i]}

+ )} +
+ ))} + +
+ + + {/* Phone */} + +
+ {phones.map((entry, i) => ( +
+ { + const next = [...phones]; + next[i] = { ...next[i], number: e.target.value }; + setPhones(next); + }} + placeholder={t("phone_placeholder")} + className="flex-1" + /> + + + - )}
- {emailErrors[i] && ( -

{emailErrors[i]}

- )} -
- ))} - -
-
+ ))} + +
+ -
- -
- {phones.map((entry, i) => ( -
- { - const next = [...phones]; - next[i] = { ...next[i], number: e.target.value }; - setPhones(next); - }} - placeholder={t("phone_placeholder")} - className="flex-1" - /> - setOrganization(e.target.value)} placeholder={t("organization_placeholder")} /> +
+
+ + setDepartment(e.target.value)} placeholder={t("department_placeholder")} /> +
+
+ + setJobTitle(e.target.value)} placeholder={t("job_title_placeholder")} /> +
+
+ + setRole(e.target.value)} placeholder={t("role_placeholder")} /> +
+
+ + + {/* Addresses — full width */} +
+ +
+ {addresses.map((addr, i) => ( +
+ + { const n = [...addresses]; n[i] = { ...n[i], street: e.target.value }; setAddresses(n); }} placeholder={t("street")} /> +
+ { const n = [...addresses]; n[i] = { ...n[i], locality: e.target.value }; setAddresses(n); }} placeholder={t("city")} /> + { const n = [...addresses]; n[i] = { ...n[i], region: e.target.value }; setAddresses(n); }} placeholder={t("region")} /> +
+
+ { const n = [...addresses]; n[i] = { ...n[i], postcode: e.target.value }; setAddresses(n); }} placeholder={t("postcode")} /> + { const n = [...addresses]; n[i] = { ...n[i], country: e.target.value }; setAddresses(n); }} placeholder={t("country")} /> + +
+
+ ))} + +
+
+
+ + {/* Online Services */} + +
+ {onlineServices.map((svc, i) => ( +
+ { const n = [...onlineServices]; n[i] = { ...n[i], uri: e.target.value }; setOnlineServices(n); }} + placeholder={t("url_placeholder")} + className="flex-1" + /> + { const n = [...onlineServices]; n[i] = { ...n[i], service: e.target.value }; setOnlineServices(n); }} + placeholder={t("service_placeholder")} + className="w-24" + /> + +
+ ))} + +
+
+ + {/* Anniversaries */} + +
+ {anniversaries.map((ann, i) => ( +
+ { const n = [...anniversaries]; n[i] = { ...n[i], date: e.target.value }; setAnniversaries(n); }} + className="flex-1" + /> + + +
+ ))} + +
+
+ + {/* Personal Info */} + +
+ {personalInfoEntries.map((pi, i) => ( +
+ { const n = [...personalInfoEntries]; n[i] = { ...n[i], value: e.target.value }; setPersonalInfoEntries(n); }} + placeholder={t("personal_info_placeholder")} + className="flex-1" + /> + + + +
+ ))} + +
+
+ + {/* Categories */} + +
+ setKeywordsStr(e.target.value)} + placeholder={t("categories_placeholder")} + /> +

{t("categories_hint")}

+
+
+ + {/* Gender */} + +
+
+ + - + + + + + +
- ))} - +
+ + setGenderIdentity(e.target.value)} placeholder={t("gender_identity_placeholder")} /> +
+
+
+ + {/* Calendar */} + +
+
+ + setCalendarUri(e.target.value)} placeholder="https://..." /> +
+
+ + setSchedulingUri(e.target.value)} placeholder="https://..." /> +
+
+ + setFreeBusyUri(e.target.value)} placeholder="https://..." /> +
+
+
+ + {/* Notes — full width */} +
+ +