From 089583a9ef358c796b5f870da379d7ffb1d44adb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:04:36 +0100 Subject: [PATCH] feat(contacts): add no-category filter, drag-drop to category, and category combo box - Add 'No Category' sidebar item to filter uncategorized contacts - Categories section now always visible (not just when keywords exist) - Add drag-and-drop support on category items in sidebar to assign keywords - Fix effectAllowed mismatch (move -> copyMove) for category drop targets - Replace plain text categories input with combo box in contact edit form - Shows existing categories as clickable suggestions - Displays assigned categories as removable badges - Supports adding new categories inline - Add translations for all 8 locales --- app/[locale]/contacts/page.tsx | 45 +++++- components/contacts/contact-form.tsx | 160 +++++++++++++++++++-- components/contacts/contact-list-item.tsx | 2 +- components/contacts/contacts-sidebar.tsx | 162 ++++++++++++++++------ locales/de/common.json | 6 +- locales/en/common.json | 6 +- locales/es/common.json | 6 +- locales/fr/common.json | 6 +- locales/it/common.json | 6 +- locales/ja/common.json | 6 +- locales/nl/common.json | 6 +- locales/pt/common.json | 6 +- 12 files changed, 357 insertions(+), 60 deletions(-) diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 5f94ff13..1b8bc214 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -126,9 +126,24 @@ export default function ContactsPage() { const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null; const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : []; + // Collect all unique keywords across contacts + const allKeywords = useMemo(() => { + const kws = new Set(); + for (const contact of individuals) { + if (!contact.keywords) continue; + for (const [kw, active] of Object.entries(contact.keywords)) { + if (active) kws.add(kw); + } + } + return Array.from(kws).sort((a, b) => a.localeCompare(b)); + }, [individuals]); + // Contacts to display based on active category const displayedContacts = useMemo(() => { if (activeCategory === "all") return individuals; + if (activeCategory === "uncategorized") { + return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0); + } if ("addressBookId" in activeCategory) { const bookId = activeCategory.addressBookId; return individuals.filter(c => { @@ -146,6 +161,7 @@ export default function ContactsPage() { // 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"); @@ -182,6 +198,31 @@ export default function ContactsPage() { } }, [client, moveContactToAddressBook, t]); + const handleDropContactsToCategory = useCallback(async (contactIds: string[], keyword: string) => { + if (!client && supportsSync) return; + try { + for (const contactId of contactIds) { + const contact = contacts.find(c => c.id === contactId); + if (!contact) continue; + const existingKeywords = contact.keywords || {}; + if (existingKeywords[keyword]) continue; // already has this keyword + const updatedKeywords = { ...existingKeywords, [keyword]: true }; + if (supportsSync && client) { + await updateContact(client, contactId, { keywords: updatedKeywords }); + } else { + updateLocalContact(contactId, { keywords: updatedKeywords }); + } + } + const msg = contactIds.length === 1 + ? t("category_added", { name: keyword }) + : t("category_added_plural", { count: contactIds.length, name: keyword }); + toast.success(msg); + } catch (error) { + console.error('Failed to add contacts to category:', error); + toast.error(t("toast.error_update")); + } + }, [client, supportsSync, contacts, updateContact, updateLocalContact, t]); + const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => { return importContacts( supportsSync && client ? client : null, @@ -430,7 +471,7 @@ export default function ContactsPage() { const renderRightPanel = () => { switch (view) { case "create": - return ; + return ; case "edit": if (!selectedContact) return null; @@ -438,6 +479,7 @@ export default function ContactsPage() { @@ -590,6 +632,7 @@ export default function ContactsPage() { onEditGroup={handleEditGroupFromSidebar} onDeleteGroup={handleDeleteGroupFromSidebar} onDropContacts={handleDropContacts} + onDropContactsToCategory={handleDropContactsToCategory} /> ) => Promise; onCancel: () => void; } @@ -123,7 +124,7 @@ function Select({ value, onChange, children, className }: { ); } -export function ContactForm({ contact, addressBooks, onSave, onCancel }: ContactFormProps) { +export function ContactForm({ contact, addressBooks, allKeywords, onSave, onCancel }: ContactFormProps) { const t = useTranslations("contacts.form"); const isEditing = !!contact; @@ -819,14 +820,14 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact {/* Categories */} -
- setKeywordsStr(e.target.value)} - placeholder={t("categories_placeholder")} - /> -

{t("categories_hint")}

-
+
{/* Gender */} @@ -895,3 +896,142 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact ); } + +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 wrapperRef = useRef(null); + const inputRef = useRef(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]); + + // Close dropdown on outside click + useEffect(() => { + if (!isOpen) return; + const handler = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [isOpen]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (inputValue.trim()) { + addKeyword(inputValue); + } + } else if (e.key === "Escape") { + setIsOpen(false); + } + }; + + return ( +
+ {/* Keyword badges */} + {currentKeywords.length > 0 && ( +
+ {currentKeywords.map(kw => ( + + {kw} + + + ))} +
+ )} + + {/* Input with dropdown */} + { setInputValue(e.target.value); setIsOpen(true); }} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + placeholder={currentKeywords.length === 0 ? placeholder : ""} + /> +

{hint}

+ + {/* Dropdown */} + {isOpen && (suggestions.length > 0 || canAddNew) && ( +
+ {suggestions.map(kw => ( + + ))} + {canAddNew && ( + + )} +
+ )} +
+ ); +} diff --git a/components/contacts/contact-list-item.tsx b/components/contacts/contact-list-item.tsx index ccb13479..2b78a634 100644 --- a/components/contacts/contact-list-item.tsx +++ b/components/contacts/contact-list-item.tsx @@ -32,7 +32,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection, ? Array.from(selectedContactIds) : [contact.id]; - e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.effectAllowed = "copyMove"; e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids)); e.dataTransfer.setData("text/plain", name || email || contact.id); diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx index 7973c2b3..f4f5cd45 100644 --- a/components/contacts/contacts-sidebar.tsx +++ b/components/contacts/contacts-sidebar.tsx @@ -10,7 +10,7 @@ import { cn } from "@/lib/utils"; import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import { getContactDisplayName } from "@/stores/contact-store"; -export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string }; +export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized"; interface ContactsSidebarProps { groups: ContactCard[]; @@ -24,6 +24,7 @@ interface ContactsSidebarProps { onEditGroup?: (groupId: string) => void; onDeleteGroup?: (groupId: string) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; + onDropContactsToCategory?: (contactIds: string[], keyword: string) => void; className?: string; } @@ -56,6 +57,7 @@ export function ContactsSidebar({ onEditGroup, onDeleteGroup, onDropContacts, + onDropContactsToCategory, className, }: ContactsSidebarProps) { const t = useTranslations("contacts"); @@ -146,6 +148,11 @@ export function ContactsSidebar({ return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)); }, [individuals]); + // Count of contacts without any keywords + const uncategorizedCount = useMemo(() => { + return individuals.filter(c => !c.keywords || Object.keys(c.keywords).filter(k => c.keywords![k]).length === 0).length; + }, [individuals]); + // Resolve actual group member counts against living contacts const memberCountByGroup = useMemo(() => { const counts: Record = {}; @@ -311,46 +318,56 @@ export function ContactsSidebar({ )} {/* Categories section (from contact keywords) */} - {allKeywords.length > 0 && ( -
- +
+ - {!collapsed.categories && allKeywords.map(([keyword, count]) => { - const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword; - return ( - - ); - })} -
- )} + {!collapsed.categories && ( + <> + {/* No Category item */} + + {allKeywords.map(([keyword, count]) => { + const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword; + return ( + onSelectCategory({ keyword })} + onDropContacts={onDropContactsToCategory} + /> + ); + })} + + )} +
{/* Shared accounts with address books */} {sharedBookGroups.map((group) => ( @@ -415,6 +432,71 @@ export function ContactsSidebar({ ); } +function CategoryItem({ + keyword, + count, + isActive, + onSelect, + onDropContacts, +}: { + keyword: string; + count: number; + isActive: boolean; + onSelect: () => void; + onDropContacts?: (contactIds: string[], keyword: string) => void; +}) { + const [isDragOver, setIsDragOver] = useState(false); + + const handleDragOver = useCallback((e: DragEvent) => { + if (!e.dataTransfer.types.includes("application/x-contact-ids")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setIsDragOver(true); + }, []); + + const handleDragLeave = useCallback(() => { + setIsDragOver(false); + }, []); + + const handleDrop = useCallback((e: DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const data = e.dataTransfer.getData("application/x-contact-ids"); + if (!data || !onDropContacts) return; + try { + const contactIds = JSON.parse(data) as string[]; + if (contactIds.length > 0) { + onDropContacts(contactIds, keyword); + } + } catch { + // ignore invalid data + } + }, [keyword, onDropContacts]); + + return ( + + ); +} + function AddressBookItem({ book, isActive, diff --git a/locales/de/common.json b/locales/de/common.json index df101114..cc3dd9b5 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1482,6 +1482,9 @@ "title": "Kontakte", "search_placeholder": "Kontakte suchen...", "create_new": "Neuer Kontakt", + "no_category": "Ohne Kategorie", + "category_added": "Kontakt zu {name} hinzugefügt", + "category_added_plural": "{count} Kontakte zu {name} hinzugefügt", "empty_state": "Keine Kontakte", "empty_state_title": "Keine Kontakte", "empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei", @@ -1625,7 +1628,8 @@ "level_low": "Niedrig", "categories": "Kategorien", "categories_placeholder": "z. B. Familie, Freunde, Kollegen", - "categories_hint": "Mit Kommas trennen", + "categories_hint": "Tippen zum Suchen oder Hinzufügen", + "category_add": "Hinzufügen", "note": "Notizen", "note_placeholder": "Notiz hinzufügen...", "gender": "Geschlecht", diff --git a/locales/en/common.json b/locales/en/common.json index 175923e7..792b17e8 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1495,6 +1495,9 @@ "title": "Contacts", "search_placeholder": "Search contacts...", "create_new": "New Contact", + "no_category": "No Category", + "category_added": "Contact added to {name}", + "category_added_plural": "{count} contacts added to {name}", "empty_state": "No contacts yet", "empty_state_title": "No contacts yet", "empty_state_subtitle": "Create your first contact or import from a vCard file", @@ -1638,7 +1641,8 @@ "level_low": "Low", "categories": "Categories", "categories_placeholder": "e.g., Family, Friends, Colleagues", - "categories_hint": "Separate with commas", + "categories_hint": "Type to search or add categories", + "category_add": "Add", "note": "Notes", "note_placeholder": "Add a note...", "gender": "Gender", diff --git a/locales/es/common.json b/locales/es/common.json index 2c88f7f4..3e1ca3a6 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1482,6 +1482,9 @@ "title": "Contactos", "search_placeholder": "Buscar contactos...", "create_new": "Nuevo contacto", + "no_category": "Sin categoría", + "category_added": "Contacto añadido a {name}", + "category_added_plural": "{count} contactos añadidos a {name}", "empty_state": "No hay contactos", "empty_state_title": "Sin contactos", "empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard", @@ -1625,7 +1628,8 @@ "level_low": "Bajo", "categories": "Categorías", "categories_placeholder": "p. ej., Familia, Amigos, Colegas", - "categories_hint": "Separar con comas", + "categories_hint": "Escriba para buscar o añadir categorías", + "category_add": "Añadir", "note": "Notas", "note_placeholder": "Agregar una nota...", "gender": "Género", diff --git a/locales/fr/common.json b/locales/fr/common.json index e413d102..fd8fa9c5 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1482,6 +1482,9 @@ "title": "Contacts", "search_placeholder": "Rechercher des contacts...", "create_new": "Nouveau contact", + "no_category": "Sans catégorie", + "category_added": "Contact ajouté à {name}", + "category_added_plural": "{count} contacts ajoutés à {name}", "empty_state": "Aucun contact", "empty_state_title": "Aucun contact", "empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard", @@ -1625,7 +1628,8 @@ "level_low": "Faible", "categories": "Catégories", "categories_placeholder": "p. ex., Famille, Amis, Collègues", - "categories_hint": "Séparer par des virgules", + "categories_hint": "Tapez pour rechercher ou ajouter", + "category_add": "Ajouter", "note": "Notes", "note_placeholder": "Ajouter une note...", "gender": "Genre", diff --git a/locales/it/common.json b/locales/it/common.json index 3896f75e..7186eeb8 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1482,6 +1482,9 @@ "title": "Contatti", "search_placeholder": "Cerca contatti...", "create_new": "Nuovo contatto", + "no_category": "Senza categoria", + "category_added": "Contatto aggiunto a {name}", + "category_added_plural": "{count} contatti aggiunti a {name}", "empty_state": "Nessun contatto", "empty_state_title": "Nessun contatto", "empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard", @@ -1625,7 +1628,8 @@ "level_low": "Basso", "categories": "Categorie", "categories_placeholder": "es., Famiglia, Amici, Colleghi", - "categories_hint": "Separare con virgole", + "categories_hint": "Digita per cercare o aggiungere", + "category_add": "Aggiungi", "note": "Note", "note_placeholder": "Aggiungi una nota...", "gender": "Genere", diff --git a/locales/ja/common.json b/locales/ja/common.json index 93795999..d35aee06 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1482,6 +1482,9 @@ "title": "連絡先", "search_placeholder": "連絡先を検索...", "create_new": "新しい連絡先", + "no_category": "カテゴリなし", + "category_added": "{name} に連絡先を追加しました", + "category_added_plural": "{count} 件の連絡先を {name} に追加しました", "empty_state": "連絡先がありません", "empty_state_title": "連絡先がありません", "empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください", @@ -1625,7 +1628,8 @@ "level_low": "低", "categories": "カテゴリー", "categories_placeholder": "例:家族、友人、同僚", - "categories_hint": "カンマで区切ってください", + "categories_hint": "検索または追加するには入力", + "category_add": "追加", "note": "メモ", "note_placeholder": "メモを追加...", "gender": "性別", diff --git a/locales/nl/common.json b/locales/nl/common.json index ce46acf2..2125b3c9 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1482,6 +1482,9 @@ "title": "Contacten", "search_placeholder": "Contacten zoeken...", "create_new": "Nieuw contact", + "no_category": "Geen categorie", + "category_added": "Contact toegevoegd aan {name}", + "category_added_plural": "{count} contacten toegevoegd aan {name}", "empty_state": "Geen contacten", "empty_state_title": "Geen contacten", "empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand", @@ -1625,7 +1628,8 @@ "level_low": "Laag", "categories": "Categorieën", "categories_placeholder": "bijv. Familie, Vrienden, Collega's", - "categories_hint": "Scheiden met komma's", + "categories_hint": "Typ om te zoeken of toe te voegen", + "category_add": "Toevoegen", "note": "Notities", "note_placeholder": "Notitie toevoegen...", "gender": "Geslacht", diff --git a/locales/pt/common.json b/locales/pt/common.json index 07bd4897..8dee9921 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1482,6 +1482,9 @@ "title": "Contatos", "search_placeholder": "Pesquisar contatos...", "create_new": "Novo contato", + "no_category": "Sem categoria", + "category_added": "Contato adicionado a {name}", + "category_added_plural": "{count} contatos adicionados a {name}", "empty_state": "Nenhum contato", "empty_state_title": "Sem contatos", "empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard", @@ -1625,7 +1628,8 @@ "level_low": "Baixo", "categories": "Categorias", "categories_placeholder": "ex., Família, Amigos, Colegas", - "categories_hint": "Separar com vírgulas", + "categories_hint": "Digite para pesquisar ou adicionar", + "category_add": "Adicionar", "note": "Notas", "note_placeholder": "Adicionar uma nota...", "gender": "Gênero",