Files
webmail/components/contacts/contact-form.tsx
T
Linus Rath 6bd55c9501 fix: export contact photos in batches on Stalwart 1.0
On 1.0 a vCard export sent every blob photo through the avatar download
queue, a burst of 40 and then 6 a second, two at a time: about 160 s for
1000 photos. Avatars and print (which gives up after 5 s) waited behind
it, the Export button showed nothing, and a second click started the
whole export again.

- Export fetches the blob media of all cards with Blob/get
  (`data:asBase64`), asking for the sizes first and packing the requests
  to about 4 MiB. `urn:ietf:params:jmap:blob` goes into `using` only
  when the session advertises it; without it, or when a request is
  refused, the rest is downloaded one by one behind avatars and print,
  which keep half of the download burst for themselves.
- The export buttons in the settings, the bulk bar, the context menu and
  the detail menu show that an export is running and ignore clicks until
  it is done.
- When the photo changes, the form rebuilds `media` on the card as the
  server has it now. A logo or sound blobId from the list's older copy
  made 1.0 refuse the whole update ("The following blobIds do not exist
  or are not accessible").
- A new photo takes the key, place, `pref`, `label` and `contexts` of the
  one it replaces, so it stays the photo shown on cards with several.
  Removing the photo removes every photo the card had instead of only the
  shown one, which brought up the next; logos and sounds stay.
- A card whose login has no client downloads nothing, instead of asking
  the active login's server and remembering the refusal for two minutes.
- Photo downloads run at half the server's `maxConcurrentRequests`
  instead of a fixed two.
- `invalidProperties` naming `media/...` also counts as a refused blobId
  in media, not only `media` itself.
2026-09-25 20:05:19 +02:00

1313 lines
57 KiB
TypeScript

"use client";
import { useState, useMemo, useCallback, useRef } from "react";
import { useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { findContactPhotoEntry, getContactPhotoRef, type ContactPhotoRef } from "@/stores/contact-store";
import type { ContactSaveOptions } from "@/lib/contact-media";
import { cn } from "@/lib/utils";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
interface EmailEntry {
address: string;
context: "work" | "private" | "";
}
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 {
contact?: ContactCard | null;
addressBooks?: AddressBook[];
allKeywords?: string[];
defaultAddressBookId?: string;
/** Prefills the create form (ignored when `contact` is set). */
prefill?: { email?: string; name?: string };
/** `options.photoKey` names the `media` entry holding a newly picked photo. */
onSave: (data: Partial<ContactCard>, options?: ContactSaveOptions) => Promise<void>;
onCancel: () => void;
/**
* Reads the card as the server has it now, or null when it can't. When the
* photo changed, `media` is rebuilt on it instead of on `contact`, which
* may be older.
*/
loadCurrentMedia?: () => Promise<Pick<ContactCard, "media"> | null>;
}
/** What the user did to the photo: nothing, picked a new one (a data: URI), or removed it. */
type PhotoEdit =
| { kind: "unchanged" }
| { kind: "new"; uri: string; mediaType: string }
| { kind: "removed" };
/** A `media` key for a new photo that no other entry uses. */
function freeMediaKey(media: Record<string, ContactMedia>): string {
let key = "photo";
for (let n = 1; key in media; n++) key = `photo${n}`;
return key;
}
/**
* The `media` map to send for a photo edit, built on `base`: the card's media
* as the server has it now when it could be read, else the form's copy
* (`known`). `shownKey` is the photo the form showed.
*
* - A new photo takes the shown one's key and place, with its `pref`,
* `label` and `contexts`, so on a card with several photos it is still the
* one shown (the lowest pref wins).
* - Removing the photo removes every photo the form's copy had, not only the
* shown one: the next would otherwise take its place after the save, while
* the form showed none. Logos and sounds stay; so do photos added
* elsewhere since.
*/
function mediaForPhotoEdit(
base: Record<string, ContactMedia>,
known: Record<string, ContactMedia>,
shownKey: string | undefined,
edit: Exclude<PhotoEdit, { kind: "unchanged" }>,
): { media: Record<string, ContactMedia> | null; photoKey?: string } {
if (edit.kind === "removed") {
const others = Object.fromEntries(Object.entries(base).filter(
([key, entry]) => !(entry?.kind === "photo" && known[key]?.kind === "photo"),
));
return { media: Object.keys(others).length > 0 ? others : null };
}
const shown = shownKey ? (base[shownKey]?.kind === "photo" ? base[shownKey] : known[shownKey]) : undefined;
const photoKey = shownKey && (!base[shownKey] || base[shownKey]?.kind === "photo") ? shownKey : freeMediaKey(base);
const photo: ContactMedia = {
kind: "photo",
uri: edit.uri,
mediaType: edit.mediaType,
...(shown?.pref !== undefined ? { pref: shown.pref } : {}),
...(shown?.label !== undefined ? { label: shown.label } : {}),
...(shown?.contexts !== undefined ? { contexts: shown.contexts } : {}),
};
const media: Record<string, ContactMedia> = {};
for (const [key, entry] of Object.entries(base)) media[key] = key === photoKey ? photo : entry;
media[photoKey] = photo;
return { media, photoKey };
}
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: {
icon: React.ComponentType<{ className?: string }>;
title: string;
children: React.ReactNode;
collapsible?: boolean;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<section className="py-5">
<button
type="button"
className={cn(
"flex items-center gap-2 w-full text-start",
collapsible ? "cursor-pointer" : "cursor-default"
)}
onClick={() => collapsible && setOpen(!open)}
tabIndex={collapsible ? 0 : -1}
>
<Icon className="w-4 h-4 text-muted-foreground shrink-0" />
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
{collapsible && (
open ? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" /> : <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
{(open || !collapsible) && (
<div className="space-y-3 mt-3">
{children}
</div>
)}
</section>
);
}
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<HTMLSelectElement>) => void;
children: React.ReactNode;
className?: string;
}) {
return (
<select
value={value}
onChange={onChange}
className={cn(
"text-sm bg-transparent border border-input rounded-md px-2.5 py-2 text-foreground",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1",
"hover:border-muted-foreground/50 transition-colors",
className
)}
>
{children}
</select>
);
}
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia }: ContactFormProps) {
const t = useTranslations("contacts.form");
const isEditing = !!contact;
// Split a free-form display name into given/surname for prefill.
const prefillGivenName = (() => {
if (contact || !prefill?.name) return "";
const parts = prefill.name.trim().split(/\s+/);
return parts[0] || "";
})();
const prefillSurname = (() => {
if (contact || !prefill?.name) return "";
const parts = prefill.name.trim().split(/\s+/);
return parts.slice(1).join(" ");
})();
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
const findComponent = (...kinds: string[]) =>
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
function anniversaryDateToString(date: AnniversaryDate): string {
if (typeof date === 'string') return date;
if (date && typeof date === 'object') {
if ('@type' in date && date['@type'] === 'Timestamp' && 'utc' in date) {
return (date as { utc: string }).utc.split('T')[0];
}
const pd = date as PartialDate;
if (pd.year && pd.month && pd.day) {
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
}
if (pd.month && pd.day) {
return `--${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
}
if (pd.year && pd.month) {
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}`;
}
if (pd.year) return String(pd.year);
}
return String(date);
}
// Convert ISO date string back to RFC 9553 PartialDate for the server
function stringToPartialDate(str: string): PartialDate {
if (str.startsWith('--')) {
const parts = str.substring(2).split('-');
const pd: PartialDate = { month: parseInt(parts[0], 10) };
if (parts[1]) pd.day = parseInt(parts[1], 10);
return pd;
}
const parts = str.split('-');
const pd: PartialDate = {};
if (parts[0]) pd.year = parseInt(parts[0], 10);
if (parts[1]) pd.month = parseInt(parts[1], 10);
if (parts[2]) pd.day = parseInt(parts[2], 10);
return pd;
}
// Extract flat address fields from RFC 9553 components format
function addressToFlat(a: ContactAddress): AddressEntry {
if (a.components && a.components.length > 0) {
const findComp = (kind: string) => a.components!.filter(c => c.kind === kind).map(c => c.value).join(' ');
return {
street: findComp('name') || findComp('number') ? [findComp('number'), findComp('name')].filter(Boolean).join(' ') : '',
locality: findComp('locality'),
region: findComp('region'),
postcode: findComp('postcode'),
country: findComp('country'),
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
};
}
return {
street: a.street || '',
locality: a.locality || '',
region: a.region || '',
postcode: a.postcode || '',
country: a.country || '',
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
};
}
const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
const [givenName, setGivenName] = useState(findComponent("given") || prefillGivenName);
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
const [surname, setSurname] = useState(findComponent("surname") || prefillSurname);
const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
const [nickname, setNickname] = useState(
contact?.nicknames ? Object.values(contact.nicknames)[0]?.name || "" : ""
);
const [emails, setEmails] = useState<EmailEntry[]>(() => {
if (contact?.emails) {
return Object.values(contact.emails).map(e => ({
address: e.address,
context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "",
}));
}
return [{ address: prefill?.email || "", context: "" }];
});
const [phones, setPhones] = useState<PhoneEntry[]>(() => {
if (contact?.phones) {
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 [];
});
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 || "") : ""
);
// A card may describe an organization instead of a person (RFC 9553 kind "org").
// Older cards predate the explicit kind, so fall back to "has an org name but no
// personal name".
const [isOrg, setIsOrg] = useState(() => {
if (!contact) return false;
if (contact.kind) return contact.kind === "org";
const hasPersonName = !!(findComponent("given") || findComponent("surname"));
return !hasPersonName && !!Object.values(contact.organizations || {})[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<AddressEntry[]>(() => {
if (contact?.addresses) {
return Object.values(contact.addresses).map(a => addressToFlat(a));
}
return [];
});
const [onlineServices, setOnlineServices] = useState<OnlineServiceEntry[]>(() => {
if (contact?.onlineServices) {
return Object.values(contact.onlineServices).map(s => ({
uri: s.uri,
service: s.service || "",
label: s.label || "",
}));
}
return [];
});
const [anniversaries, setAnniversaries] = useState<AnniversaryEntry[]>(() => {
if (contact?.anniversaries) {
return Object.values(contact.anniversaries).map(a => ({
date: anniversaryDateToString(a.date),
kind: a.kind,
}));
}
return [];
});
const [personalInfoEntries, setPersonalInfoEntries] = useState<PersonalInfoEntry[]>(() => {
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?.speakToAs?.grammaticalGender || "");
const [genderIdentity, setGenderIdentity] = useState(
contact?.speakToAs?.pronouns ? Object.values(contact.speakToAs.pronouns)[0]?.pronouns || "" : ""
);
const [calendarUri, setCalendarUri] = useState(contact?.calendarUri || "");
const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || "");
const [freeBusyUri, setFreeBusyUri] = useState(contact?.freeBusyUri || "");
// Address book selection
const currentBookId = useMemo(() => {
if (contact?.addressBookIds) {
const ids = Object.keys(contact.addressBookIds).filter(k => contact.addressBookIds[k]);
if (ids.length > 0) {
// addressBookIds are already namespaced for shared contacts (e.g. "accountId:bookId")
// so we can use them directly to match addressBook entries
return ids[0];
}
}
if (defaultAddressBookId && addressBooks?.some(b => b.id === defaultAddressBookId)) {
return defaultAddressBookId;
}
// New contacts land in the account's default book unless told otherwise -
// preselect it so the form shows where the contact will actually go.
if (!contact) {
const ownDefault = addressBooks?.find(b => b.isDefault && !b.isShared);
if (ownDefault) return ownDefault.id;
}
return "";
}, [contact, defaultAddressBookId, addressBooks]);
const [selectedBookId, setSelectedBookId] = useState(currentBookId);
// The stored photo may be a data: URI (Stalwart 0.16) or a blob (1.0); the
// form only ever writes the photo the user picks, never the one it shows.
const initialPhotoKey = useMemo(() => (contact ? findContactPhotoEntry(contact)?.[0] : undefined), [contact]);
const [photoEdit, setPhotoEdit] = useState<PhotoEdit>({ kind: "unchanged" });
const previewPhoto: ContactPhotoRef | null =
photoEdit.kind === "new" ? { uri: photoEdit.uri }
: photoEdit.kind === "unchanged" && contact ? getContactPhotoRef(contact) ?? null
: null;
const [photoError, setPhotoError] = useState<string | null>(null);
const [photoUploading, setPhotoUploading] = useState(false);
const photoInputRef = useRef<HTMLInputElement>(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
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);
setPhotoEdit({ kind: "new", uri, mediaType });
} catch {
setPhotoError(t("photo_invalid"));
} finally {
setPhotoUploading(false);
}
};
const handlePhotoRemove = () => {
setPhotoEdit({ kind: "removed" });
setPhotoError(null);
};
const validateEmail = (address: string): boolean => {
if (!address.trim()) return true;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address.trim());
};
const handleEmailBlur = (index: number, address: string) => {
if (address.trim() && !validateEmail(address)) {
setEmailErrors(prev => ({ ...prev, [index]: t("email_error_inline") }));
} else {
setEmailErrors(prev => {
const next = { ...prev };
delete next[index];
return next;
});
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// An organization name identifies the card just as well as a personal name.
const orgName = organization.trim();
const hasPersonName = !!(givenName.trim() || surname.trim());
if (isOrg ? !orgName : (!hasPersonName && !orgName)) {
setError(t("name_required"));
return;
}
const validEmails = emails.filter(e => e.address.trim());
for (const entry of validEmails) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(entry.address.trim())) {
setError(t("email_invalid"));
return;
}
}
const emailsMap: Record<string, { address: string; contexts?: Record<string, boolean> }> = {};
validEmails.forEach((entry, i) => {
const obj: { address: string; contexts?: Record<string, boolean> } = { address: entry.address.trim() };
if (entry.context) {
obj.contexts = { [entry.context]: true };
}
emailsMap[`e${i}`] = obj;
});
const validPhones = phones.filter(p => p.number.trim());
const phonesMap: Record<string, { number: string; contexts?: Record<string, boolean>; features?: Record<string, boolean> }> = {};
validPhones.forEach((entry, i) => {
const obj: { number: string; contexts?: Record<string, boolean>; features?: Record<string, boolean> } = { number: entry.number.trim() };
if (entry.context) {
obj.contexts = { [entry.context]: true };
}
if (entry.feature) {
obj.features = { [entry.feature]: true };
}
phonesMap[`p${i}`] = obj;
});
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
const nameComponents = [];
if (!isOrg) {
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
}
// Always set `name.full` (RFC 9553): the vCard FN property is derived from it,
// and FN is mandatory (RFC 6350 §6.2.1) — strict CardDAV clients like Apple
// Contacts silently drop cards without it (#430).
const fullName = [prefix, givenName, additionalName, surname, suffix]
.map(s => s.trim())
.filter(Boolean)
.join(" ");
// Without personal name components, carry the organization name in `name.full`
// so servers and other clients have something to display.
const nameValue: ContactCard["name"] = nameComponents.length > 0
? { components: nameComponents, isOrdered: true, full: fullName || undefined }
: { full: orgName };
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
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<string, ContactCard["addresses"] extends Record<string, infer V> ? V : never> = {};
addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => {
const components: Array<{ kind: string; value: string }> = [];
if (a.street.trim()) components.push({ kind: "name", value: a.street.trim() });
if (a.locality.trim()) components.push({ kind: "locality", value: a.locality.trim() });
if (a.region.trim()) components.push({ kind: "region", value: a.region.trim() });
if (a.postcode.trim()) components.push({ kind: "postcode", value: a.postcode.trim() });
if (a.country.trim()) components.push({ kind: "country", value: a.country.trim() });
const obj: Record<string, unknown> = { components, isOrdered: true, defaultSeparator: ", " };
if (a.context) obj.contexts = { [a.context]: true };
// @ts-expect-error - dynamic build
addressesMap[`a${i}`] = obj;
});
const onlineServicesMap: Record<string, ContactOnlineService> = {};
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<string, ContactAnniversary> = {};
anniversaries.filter(a => a.date.trim()).forEach((a, i) => {
anniversariesMap[`an${i}`] = { date: stringToPartialDate(a.date.trim()), kind: a.kind };
});
const personalInfoMap: Record<string, ContactPersonalInfo> = {};
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<string, boolean> = {};
if (keywordsStr.trim()) {
keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
keywordsMap[k] = true;
});
}
// Only send `kind` when this form owns the answer: switching a card between
// person and organization. Leave other kinds (group, location, ...) untouched.
const kindValue: ContactCard["kind"] | undefined =
isOrg ? "org" : (contact?.kind === "org" ? "individual" : undefined);
const data: Partial<ContactCard> = {
name: nameValue,
...(kindValue ? { kind: kindValue } : {}),
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: orgName
? { o0: { name: orgName, 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,
speakToAs: (genderSex.trim() || genderIdentity.trim())
? {
grammaticalGender: genderSex.trim() || undefined,
pronouns: genderIdentity.trim() ? { p0: { pronouns: genderIdentity.trim() } } : undefined,
}
: undefined,
calendarUri: calendarUri.trim() || undefined,
schedulingUri: schedulingUri.trim() || undefined,
freeBusyUri: freeBusyUri.trim() || undefined,
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
};
setIsSaving(true);
try {
// `media` goes out only when the photo changed. An update without it
// keeps every entry on the server, while a map replaces them all, and
// `null` deletes them. The other entries go back as the server has them
// now: a data: URI on 0.16, a blobId on 1.0, which resolves it again. A
// blobId from an older copy of the card, say of a logo changed
// elsewhere since, makes 1.0 refuse the whole update ("The following
// blobIds do not exist or are not accessible").
let photoKey: string | undefined;
if (photoEdit.kind === "new" || (photoEdit.kind === "removed" && initialPhotoKey)) {
const current = contact && loadCurrentMedia ? await loadCurrentMedia().catch(() => null) : null;
const known = contact?.media ?? {};
const edited = mediaForPhotoEdit(current ? current.media ?? {} : known, known, initialPhotoKey, photoEdit);
// Absent, not undefined: contactToWire sends an undefined key as null.
data.media = edited.media as Record<string, ContactMedia>;
photoKey = edited.photoKey;
}
await (photoKey ? onSave(data, { photoKey }) : onSave(data));
} catch (err) {
setError(err instanceof Error ? err.message : t("save_failed"));
} finally {
setIsSaving(false);
}
};
const previewName = (isOrg ? "" : [givenName, surname].filter(Boolean).join(" ").trim()) || organization.trim();
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
return (
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
{isEditing ? t("edit_title") : t("create_title")}
</h2>
<button type="button" onClick={onCancel} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 max-w-3xl">
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4">
{error}
</div>
)}
<div className="flex items-center gap-4 pb-4">
<button
type="button"
onClick={() => photoInputRef.current?.click()}
disabled={photoUploading}
className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-60"
title={t("upload_photo")}
aria-label={t("upload_photo")}
>
<Avatar
name={previewName || undefined}
email={previewEmail || undefined}
contactPhoto={previewPhoto}
size="lg"
className="!w-20 !h-20 !text-xl"
/>
<span
aria-hidden="true"
className="absolute inset-0 rounded-full bg-black/55 text-white flex flex-col items-center justify-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity"
>
<Camera className="w-5 h-5" />
<span className="text-[10px] font-medium leading-none">{t("change_photo")}</span>
</span>
<input
ref={photoInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handlePhotoSelect}
/>
</button>
<div className="flex flex-col gap-1 min-w-0 flex-1">
<p className="text-xs text-muted-foreground">{t("photo_hint")}</p>
{photoError && (
<p className="text-xs text-red-600 dark:text-red-400">{photoError}</p>
)}
{previewPhoto && (
<button
type="button"
onClick={handlePhotoRemove}
className="inline-flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="w-3 h-3" />
{t("remove_photo")}
</button>
)}
</div>
</div>
<div className="divide-y divide-border/60">
{addressBooks && addressBooks.length > 1 && (
<FormSection icon={Book} title={t("section_address_book") || "Directory"}>
<select
value={selectedBookId}
onChange={(e) => setSelectedBookId(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="">{t("select_address_book") || "Select a directory..."}</option>
{addressBooks.map((book) => (
<option key={book.id} value={book.id}>
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
</option>
))}
</select>
</FormSection>
)}
<FormSection icon={User} title={t("section_identity")}>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">{t("contact_type")}</span>
<div role="radiogroup" aria-label={t("contact_type")} className="inline-flex gap-0.5 rounded-md border border-input p-0.5">
{[
{ org: false, label: t("type_person"), icon: User },
{ org: true, label: t("type_organization"), icon: Building },
].map(({ org, label, icon: Icon }) => (
<button
key={label}
type="button"
role="radio"
aria-checked={isOrg === org}
onClick={() => setIsOrg(org)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 text-xs rounded transition-colors",
isOrg === org
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
</div>
{isOrg ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("organization")} <span className="text-red-500">*</span>
</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
) : (
<>
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("given_name")} <span className="text-red-500">*</span>
</label>
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("surname")} <span className="text-red-500">*</span>
</label>
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("suffix")}</label>
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
<Input value={additionalName} onChange={(e) => setAdditionalName(e.target.value)} placeholder={t("middle_name")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
</>
)}
</FormSection>
{/* Email */}
<FormSection icon={Mail} title={t("email")}>
<div className="space-y-2">
{emails.map((entry, i) => (
<div key={i}>
<div className="flex items-center gap-2">
<Input
type="email"
inputMode="email"
value={entry.address}
onChange={(e) => {
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")}
/>
<Select
value={entry.context}
onChange={(e) => {
const next = [...emails];
next[i] = { ...next[i], context: e.target.value as EmailEntry["context"] };
setEmails(next);
}}
>
<option value="">-</option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</Select>
{emails.length > 1 && (
<Button type="button" variant="ghost" size="icon" onClick={() => setEmails(emails.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
)}
</div>
{emailErrors[i] && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ms-1">{emailErrors[i]}</p>
)}
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setEmails([...emails, { address: "", context: "" }])} className="text-xs">
<Plus className="w-3 h-3 me-1" />
{t("add_email")}
</Button>
</div>
</FormSection>
{/* Phone */}
<FormSection icon={Phone} title={t("phone")}>
<div className="space-y-2">
{phones.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<Input
type="tel"
inputMode="tel"
value={entry.number}
onChange={(e) => {
const next = [...phones];
next[i] = { ...next[i], number: e.target.value };
setPhones(next);
}}
placeholder={t("phone_placeholder")}
className="flex-1"
/>
<Select
value={entry.feature}
onChange={(e) => {
const next = [...phones];
next[i] = { ...next[i], feature: e.target.value as PhoneEntry["feature"] };
setPhones(next);
}}
className="w-[5.5rem]"
>
<option value="">{t("phone_type")}</option>
<option value="voice">{t("phone_voice")}</option>
<option value="cell">{t("phone_cell")}</option>
<option value="fax">{t("phone_fax")}</option>
<option value="pager">{t("phone_pager")}</option>
<option value="video">{t("phone_video")}</option>
<option value="text">{t("phone_text")}</option>
</Select>
<Select
value={entry.context}
onChange={(e) => {
const next = [...phones];
next[i] = { ...next[i], context: e.target.value as PhoneEntry["context"] };
setPhones(next);
}}
>
<option value="">-</option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</Select>
<Button type="button" variant="ghost" size="icon" onClick={() => setPhones(phones.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
<Plus className="w-3 h-3 me-1" />
{t("add_phone")}
</Button>
</div>
</FormSection>
{/* Work & Organization */}
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!((organization && !isOrg) || department || jobTitle || role)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* In organization mode the org name is the card's identity, edited above. */}
{!isOrg && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
</div>
)}
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("department")}</label>
<Input value={department} onChange={(e) => setDepartment(e.target.value)} placeholder={t("department_placeholder")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("job_title")}</label>
<Input value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} placeholder={t("job_title_placeholder")} />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("role")}</label>
<Input value={role} onChange={(e) => setRole(e.target.value)} placeholder={t("role_placeholder")} />
</div>
</div>
</FormSection>
{/* Addresses */}
<FormSection icon={MapPin} title={t("addresses")} collapsible defaultOpen={addresses.length > 0}>
<div className="space-y-3">
{addresses.map((addr, i) => (
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
<Button type="button" variant="ghost" size="icon" onClick={() => setAddresses(addresses.filter((_, j) => j !== i))} className="h-6 w-6 absolute top-2 right-2">
<X className="w-3 h-3" />
</Button>
<Input value={addr.street} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], street: e.target.value }; setAddresses(n); }} placeholder={t("street")} />
<div className="grid grid-cols-2 gap-2">
<Input value={addr.locality} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], locality: e.target.value }; setAddresses(n); }} placeholder={t("city")} />
<Input value={addr.region} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], region: e.target.value }; setAddresses(n); }} placeholder={t("region")} />
</div>
<div className="grid grid-cols-3 gap-2">
<Input value={addr.postcode} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], postcode: e.target.value }; setAddresses(n); }} placeholder={t("postcode")} />
<Input value={addr.country} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], country: e.target.value }; setAddresses(n); }} placeholder={t("country")} />
<Select
value={addr.context}
onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
>
<option value="">-</option>
<option value="work">{t("context_work")}</option>
<option value="private">{t("context_private")}</option>
</Select>
</div>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
<Plus className="w-3 h-3 me-1" />
{t("add_address")}
</Button>
</div>
</FormSection>
{/* Online Services */}
<FormSection icon={Globe} title={t("online_services")} collapsible defaultOpen={onlineServices.length > 0}>
<div className="space-y-2">
{onlineServices.map((svc, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={svc.uri}
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], uri: e.target.value }; setOnlineServices(n); }}
placeholder={t("url_placeholder")}
className="flex-1"
/>
<Input
value={svc.service}
onChange={(e) => { const n = [...onlineServices]; n[i] = { ...n[i], service: e.target.value }; setOnlineServices(n); }}
placeholder={t("service_placeholder")}
className="w-24"
/>
<Button type="button" variant="ghost" size="icon" onClick={() => setOnlineServices(onlineServices.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
<Plus className="w-3 h-3 me-1" />
{t("add_online_service")}
</Button>
</div>
</FormSection>
{/* Anniversaries */}
<FormSection icon={Cake} title={t("anniversaries")} collapsible defaultOpen={anniversaries.length > 0}>
<div className="space-y-2">
{anniversaries.map((ann, i) => (
<div key={i} className="flex items-center gap-2">
<Input
type="date"
value={ann.date}
onChange={(e) => { const n = [...anniversaries]; n[i] = { ...n[i], date: e.target.value }; setAnniversaries(n); }}
className="flex-1"
/>
<Select
value={ann.kind}
onChange={(e) => { const n = [...anniversaries]; n[i] = { ...n[i], kind: e.target.value as AnniversaryEntry["kind"] }; setAnniversaries(n); }}
>
<option value="birth">{t("anniversary_birth")}</option>
<option value="wedding">{t("anniversary_wedding")}</option>
<option value="death">{t("anniversary_death")}</option>
<option value="other">{t("anniversary_other")}</option>
</Select>
<Button type="button" variant="ghost" size="icon" onClick={() => setAnniversaries(anniversaries.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
<Plus className="w-3 h-3 me-1" />
{t("add_anniversary")}
</Button>
</div>
</FormSection>
{/* Personal Info */}
<FormSection icon={Heart} title={t("personal_info")} collapsible defaultOpen={personalInfoEntries.length > 0}>
<div className="space-y-2">
{personalInfoEntries.map((pi, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={pi.value}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], value: e.target.value }; setPersonalInfoEntries(n); }}
placeholder={t("personal_info_placeholder")}
className="flex-1"
/>
<Select
value={pi.kind}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], kind: e.target.value as PersonalInfoEntry["kind"] }; setPersonalInfoEntries(n); }}
>
<option value="expertise">{t("personal_expertise")}</option>
<option value="hobby">{t("personal_hobby")}</option>
<option value="interest">{t("personal_interest")}</option>
<option value="other">{t("personal_other")}</option>
</Select>
<Select
value={pi.level}
onChange={(e) => { const n = [...personalInfoEntries]; n[i] = { ...n[i], level: e.target.value as PersonalInfoEntry["level"] }; setPersonalInfoEntries(n); }}
>
<option value="">{t("level")}</option>
<option value="high">{t("level_high")}</option>
<option value="medium">{t("level_medium")}</option>
<option value="low">{t("level_low")}</option>
</Select>
<Button type="button" variant="ghost" size="icon" onClick={() => setPersonalInfoEntries(personalInfoEntries.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
<Plus className="w-3 h-3 me-1" />
{t("add_personal_info")}
</Button>
</div>
</FormSection>
{/* Categories */}
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen={!!keywordsStr}>
<CategoryComboBox
keywordsStr={keywordsStr}
onChange={setKeywordsStr}
allKeywords={allKeywords || []}
placeholder={t("categories_placeholder")}
hint={t("categories_hint")}
addLabel={t("category_add")}
/>
</FormSection>
{/* Gender */}
<FormSection icon={UserCircle} title={t("gender")} collapsible defaultOpen={!!(genderSex || genderIdentity)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
<option value="">-</option>
<option value="masculine">{t("gender_male")}</option>
<option value="feminine">{t("gender_female")}</option>
<option value="other">{t("gender_other")}</option>
<option value="none">{t("gender_none")}</option>
<option value="unknown">{t("gender_unknown")}</option>
</Select>
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_identity")}</label>
<Input value={genderIdentity} onChange={(e) => setGenderIdentity(e.target.value)} placeholder={t("gender_identity_placeholder")} />
</div>
</div>
</FormSection>
{/* Calendar */}
<FormSection icon={Calendar} title={t("calendar")} collapsible defaultOpen={!!(calendarUri || schedulingUri || freeBusyUri)}>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("calendar_uri")}</label>
<Input value={calendarUri} onChange={(e) => setCalendarUri(e.target.value)} placeholder="https://..." />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("scheduling_uri")}</label>
<Input value={schedulingUri} onChange={(e) => setSchedulingUri(e.target.value)} placeholder="https://..." />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("freebusy_uri")}</label>
<Input value={freeBusyUri} onChange={(e) => setFreeBusyUri(e.target.value)} placeholder="https://..." />
</div>
</div>
</FormSection>
{/* Notes */}
<FormSection icon={StickyNote} title={t("note")} collapsible defaultOpen={!!note}>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("note_placeholder")}
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground resize-y outline-none focus:ring-2 focus:ring-ring"
/>
</FormSection>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border flex-shrink-0">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
{t("cancel")}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (isEditing ? t("updating") : t("creating")) : t("save")}
</Button>
</div>
</form>
);
}
function CategoryComboBox({
keywordsStr,
onChange,
allKeywords,
placeholder,
hint,
addLabel,
}: {
keywordsStr: string;
onChange: (value: string) => void;
allKeywords: string[];
placeholder: string;
hint: string;
addLabel: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
// Parse current keywords from comma-separated string
const currentKeywords = useMemo(() => {
return keywordsStr.split(",").map(k => k.trim()).filter(Boolean);
}, [keywordsStr]);
// Suggestions: existing keywords not already selected
const suggestions = useMemo(() => {
const lower = inputValue.toLowerCase();
return allKeywords.filter(kw =>
!currentKeywords.includes(kw) &&
(!lower || kw.toLowerCase().includes(lower))
);
}, [allKeywords, currentKeywords, inputValue]);
// Can add a new keyword if typed text is non-empty and not already in the list
const canAddNew = inputValue.trim() &&
!currentKeywords.includes(inputValue.trim()) &&
!allKeywords.some(kw => kw.toLowerCase() === inputValue.trim().toLowerCase());
const addKeyword = useCallback((keyword: string) => {
const trimmed = keyword.trim();
if (!trimmed || currentKeywords.includes(trimmed)) return;
const next = [...currentKeywords, trimmed].join(", ");
onChange(next);
setInputValue("");
}, [currentKeywords, onChange]);
const removeKeyword = useCallback((keyword: string) => {
const next = currentKeywords.filter(k => k !== keyword).join(", ");
onChange(next);
}, [currentKeywords, onChange]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
if (inputValue.trim()) {
addKeyword(inputValue);
}
} else if (e.key === "Escape") {
setIsOpen(false);
}
};
return (
<div className="relative">
{/* Keyword badges */}
{currentKeywords.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{currentKeywords.map(kw => (
<span
key={kw}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary border border-primary/20"
>
{kw}
<button
type="button"
onClick={() => removeKeyword(kw)}
className="hover:text-destructive transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{/* Input with dropdown */}
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
onBlur={() => setIsOpen(false)}
onKeyDown={handleKeyDown}
placeholder={currentKeywords.length === 0 ? placeholder : ""}
/>
<p className="text-xs text-muted-foreground mt-1.5">{hint}</p>
{/* Dropdown */}
{isOpen && (suggestions.length > 0 || canAddNew) && (
<div className="absolute left-0 right-0 top-[calc(100%-1.5rem)] mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md z-50 max-h-48 overflow-y-auto py-1" onMouseDown={(e) => e.preventDefault()}>
{suggestions.map(kw => (
<button
key={kw}
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
>
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
{kw}
</button>
))}
{canAddNew && (
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start text-primary"
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
>
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
{addLabel}: &quot;{inputValue.trim()}&quot;
</button>
)}
</div>
)}
</div>
);
}