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
This commit is contained in:
Linus Rath
2026-03-21 03:04:36 +01:00
parent 2d834213ee
commit 089583a9ef
12 changed files with 357 additions and 60 deletions
+44 -1
View File
@@ -126,9 +126,24 @@ export default function ContactsPage() {
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null; const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : []; const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
// Collect all unique keywords across contacts
const allKeywords = useMemo(() => {
const kws = new Set<string>();
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 // Contacts to display based on active category
const displayedContacts = useMemo(() => { const displayedContacts = useMemo(() => {
if (activeCategory === "all") return individuals; 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) { if ("addressBookId" in activeCategory) {
const bookId = activeCategory.addressBookId; const bookId = activeCategory.addressBookId;
return individuals.filter(c => { return individuals.filter(c => {
@@ -146,6 +161,7 @@ export default function ContactsPage() {
// Label for the current category // Label for the current category
const categoryLabel = useMemo(() => { const categoryLabel = useMemo(() => {
if (activeCategory === "all") return t("tabs.all"); if (activeCategory === "all") return t("tabs.all");
if (activeCategory === "uncategorized") return t("no_category");
if ("addressBookId" in activeCategory) { if ("addressBookId" in activeCategory) {
const book = addressBooks.find(b => b.id === activeCategory.addressBookId); const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
return book?.name || t("tabs.all"); return book?.name || t("tabs.all");
@@ -182,6 +198,31 @@ export default function ContactsPage() {
} }
}, [client, moveContactToAddressBook, t]); }, [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[]) => { const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts( return importContacts(
supportsSync && client ? client : null, supportsSync && client ? client : null,
@@ -430,7 +471,7 @@ export default function ContactsPage() {
const renderRightPanel = () => { const renderRightPanel = () => {
switch (view) { switch (view) {
case "create": case "create":
return <ContactForm addressBooks={addressBooks} onSave={handleSaveNew} onCancel={handleCancel} />; return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
case "edit": case "edit":
if (!selectedContact) return null; if (!selectedContact) return null;
@@ -438,6 +479,7 @@ export default function ContactsPage() {
<ContactForm <ContactForm
contact={selectedContact} contact={selectedContact}
addressBooks={addressBooks} addressBooks={addressBooks}
allKeywords={allKeywords}
onSave={handleSaveEdit} onSave={handleSaveEdit}
onCancel={handleCancel} onCancel={handleCancel}
/> />
@@ -590,6 +632,7 @@ export default function ContactsPage() {
onEditGroup={handleEditGroupFromSidebar} onEditGroup={handleEditGroupFromSidebar}
onDeleteGroup={handleDeleteGroupFromSidebar} onDeleteGroup={handleDeleteGroupFromSidebar}
onDropContacts={handleDropContacts} onDropContacts={handleDropContacts}
onDropContactsToCategory={handleDropContactsToCategory}
/> />
</div> </div>
<ResizeHandle <ResizeHandle
+150 -10
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react"; import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -48,6 +48,7 @@ interface AddressEntry {
interface ContactFormProps { interface ContactFormProps {
contact?: ContactCard | null; contact?: ContactCard | null;
addressBooks?: AddressBook[]; addressBooks?: AddressBook[];
allKeywords?: string[];
onSave: (data: Partial<ContactCard>) => Promise<void>; onSave: (data: Partial<ContactCard>) => Promise<void>;
onCancel: () => void; 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 t = useTranslations("contacts.form");
const isEditing = !!contact; const isEditing = !!contact;
@@ -819,14 +820,14 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
{/* Categories */} {/* Categories */}
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital"> <FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
<div> <CategoryComboBox
<Input keywordsStr={keywordsStr}
value={keywordsStr} onChange={setKeywordsStr}
onChange={(e) => setKeywordsStr(e.target.value)} allKeywords={allKeywords || []}
placeholder={t("categories_placeholder")} placeholder={t("categories_placeholder")}
/> hint={t("categories_hint")}
<p className="text-xs text-muted-foreground mt-1.5">{t("categories_hint")}</p> addLabel={t("category_add")}
</div> />
</FormSection> </FormSection>
{/* Gender */} {/* Gender */}
@@ -895,3 +896,142 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
</form> </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 wrapperRef = useRef<HTMLDivElement>(null);
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]);
// 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 (
<div ref={wrapperRef} 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)}
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">
{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-left"
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-left 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>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
? Array.from(selectedContactIds) ? Array.from(selectedContactIds)
: [contact.id]; : [contact.id];
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids)); e.dataTransfer.setData("application/x-contact-ids", JSON.stringify(ids));
e.dataTransfer.setData("text/plain", name || email || contact.id); e.dataTransfer.setData("text/plain", name || email || contact.id);
+122 -40
View File
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
import type { ContactCard, AddressBook } from "@/lib/jmap/types"; import type { ContactCard, AddressBook } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store"; 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 { interface ContactsSidebarProps {
groups: ContactCard[]; groups: ContactCard[];
@@ -24,6 +24,7 @@ interface ContactsSidebarProps {
onEditGroup?: (groupId: string) => void; onEditGroup?: (groupId: string) => void;
onDeleteGroup?: (groupId: string) => void; onDeleteGroup?: (groupId: string) => void;
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void; onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
onDropContactsToCategory?: (contactIds: string[], keyword: string) => void;
className?: string; className?: string;
} }
@@ -56,6 +57,7 @@ export function ContactsSidebar({
onEditGroup, onEditGroup,
onDeleteGroup, onDeleteGroup,
onDropContacts, onDropContacts,
onDropContactsToCategory,
className, className,
}: ContactsSidebarProps) { }: ContactsSidebarProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
@@ -146,6 +148,11 @@ export function ContactsSidebar({
return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)); return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
}, [individuals]); }, [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 // Resolve actual group member counts against living contacts
const memberCountByGroup = useMemo(() => { const memberCountByGroup = useMemo(() => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
@@ -311,46 +318,56 @@ export function ContactsSidebar({
)} )}
{/* Categories section (from contact keywords) */} {/* Categories section (from contact keywords) */}
{allKeywords.length > 0 && ( <div className="mt-2">
<div className="mt-2"> <button
<button onClick={() => toggleSection("categories")}
onClick={() => toggleSection("categories")} className="flex items-center gap-1 px-3 py-1 w-full text-left group"
className="flex items-center gap-1 px-3 py-1 w-full text-left group" >
> {collapsed.categories ? (
{collapsed.categories ? ( <ChevronRight className="w-3 h-3 text-muted-foreground" />
<ChevronRight className="w-3 h-3 text-muted-foreground" /> ) : (
) : ( <ChevronDown className="w-3 h-3 text-muted-foreground" />
<ChevronDown className="w-3 h-3 text-muted-foreground" /> )}
)} <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> {t("detail.categories")}
{t("detail.categories")} </span>
</span> </button>
</button>
{!collapsed.categories && allKeywords.map(([keyword, count]) => { {!collapsed.categories && (
const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword; <>
return ( {/* No Category item */}
<button <button
key={keyword} onClick={() => onSelectCategory("uncategorized")}
onClick={() => onSelectCategory({ keyword })} className={cn(
className={cn( "w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors", activeCategory === "uncategorized"
isActive ? "bg-accent text-accent-foreground font-medium"
? "bg-accent text-accent-foreground font-medium" : "text-foreground/80 hover:bg-muted"
: "text-foreground/80 hover:bg-muted" )}
)} style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }} >
> <Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
<Tag className="w-3.5 h-3.5 flex-shrink-0" /> <span className="truncate italic">{t("no_category")}</span>
<span className="truncate">{keyword}</span> <span className="ml-auto text-xs text-muted-foreground tabular-nums">
<span className="ml-auto text-xs text-muted-foreground tabular-nums"> {uncategorizedCount}
{count} </span>
</span> </button>
</button> {allKeywords.map(([keyword, count]) => {
); const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword;
})} return (
</div> <CategoryItem
)} key={keyword}
keyword={keyword}
count={count}
isActive={isActive}
onSelect={() => onSelectCategory({ keyword })}
onDropContacts={onDropContactsToCategory}
/>
);
})}
</>
)}
</div>
{/* Shared accounts with address books */} {/* Shared accounts with address books */}
{sharedBookGroups.map((group) => ( {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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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 (
<button
onClick={onSelect}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
isDragOver && "bg-primary/20 ring-2 ring-primary/50"
)}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
>
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{keyword}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{count}
</span>
</button>
);
}
function AddressBookItem({ function AddressBookItem({
book, book,
isActive, isActive,
+5 -1
View File
@@ -1482,6 +1482,9 @@
"title": "Kontakte", "title": "Kontakte",
"search_placeholder": "Kontakte suchen...", "search_placeholder": "Kontakte suchen...",
"create_new": "Neuer Kontakt", "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": "Keine Kontakte",
"empty_state_title": "Keine Kontakte", "empty_state_title": "Keine Kontakte",
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei", "empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
@@ -1625,7 +1628,8 @@
"level_low": "Niedrig", "level_low": "Niedrig",
"categories": "Kategorien", "categories": "Kategorien",
"categories_placeholder": "z. B. Familie, Freunde, Kollegen", "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": "Notizen",
"note_placeholder": "Notiz hinzufügen...", "note_placeholder": "Notiz hinzufügen...",
"gender": "Geschlecht", "gender": "Geschlecht",
+5 -1
View File
@@ -1495,6 +1495,9 @@
"title": "Contacts", "title": "Contacts",
"search_placeholder": "Search contacts...", "search_placeholder": "Search contacts...",
"create_new": "New Contact", "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": "No contacts yet",
"empty_state_title": "No contacts yet", "empty_state_title": "No contacts yet",
"empty_state_subtitle": "Create your first contact or import from a vCard file", "empty_state_subtitle": "Create your first contact or import from a vCard file",
@@ -1638,7 +1641,8 @@
"level_low": "Low", "level_low": "Low",
"categories": "Categories", "categories": "Categories",
"categories_placeholder": "e.g., Family, Friends, Colleagues", "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": "Notes",
"note_placeholder": "Add a note...", "note_placeholder": "Add a note...",
"gender": "Gender", "gender": "Gender",
+5 -1
View File
@@ -1482,6 +1482,9 @@
"title": "Contactos", "title": "Contactos",
"search_placeholder": "Buscar contactos...", "search_placeholder": "Buscar contactos...",
"create_new": "Nuevo contacto", "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": "No hay contactos",
"empty_state_title": "Sin contactos", "empty_state_title": "Sin contactos",
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard", "empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
@@ -1625,7 +1628,8 @@
"level_low": "Bajo", "level_low": "Bajo",
"categories": "Categorías", "categories": "Categorías",
"categories_placeholder": "p. ej., Familia, Amigos, Colegas", "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": "Notas",
"note_placeholder": "Agregar una nota...", "note_placeholder": "Agregar una nota...",
"gender": "Género", "gender": "Género",
+5 -1
View File
@@ -1482,6 +1482,9 @@
"title": "Contacts", "title": "Contacts",
"search_placeholder": "Rechercher des contacts...", "search_placeholder": "Rechercher des contacts...",
"create_new": "Nouveau contact", "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": "Aucun contact",
"empty_state_title": "Aucun contact", "empty_state_title": "Aucun contact",
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard", "empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
@@ -1625,7 +1628,8 @@
"level_low": "Faible", "level_low": "Faible",
"categories": "Catégories", "categories": "Catégories",
"categories_placeholder": "p. ex., Famille, Amis, Collègues", "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": "Notes",
"note_placeholder": "Ajouter une note...", "note_placeholder": "Ajouter une note...",
"gender": "Genre", "gender": "Genre",
+5 -1
View File
@@ -1482,6 +1482,9 @@
"title": "Contatti", "title": "Contatti",
"search_placeholder": "Cerca contatti...", "search_placeholder": "Cerca contatti...",
"create_new": "Nuovo contatto", "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": "Nessun contatto",
"empty_state_title": "Nessun contatto", "empty_state_title": "Nessun contatto",
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard", "empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
@@ -1625,7 +1628,8 @@
"level_low": "Basso", "level_low": "Basso",
"categories": "Categorie", "categories": "Categorie",
"categories_placeholder": "es., Famiglia, Amici, Colleghi", "categories_placeholder": "es., Famiglia, Amici, Colleghi",
"categories_hint": "Separare con virgole", "categories_hint": "Digita per cercare o aggiungere",
"category_add": "Aggiungi",
"note": "Note", "note": "Note",
"note_placeholder": "Aggiungi una nota...", "note_placeholder": "Aggiungi una nota...",
"gender": "Genere", "gender": "Genere",
+5 -1
View File
@@ -1482,6 +1482,9 @@
"title": "連絡先", "title": "連絡先",
"search_placeholder": "連絡先を検索...", "search_placeholder": "連絡先を検索...",
"create_new": "新しい連絡先", "create_new": "新しい連絡先",
"no_category": "カテゴリなし",
"category_added": "{name} に連絡先を追加しました",
"category_added_plural": "{count} 件の連絡先を {name} に追加しました",
"empty_state": "連絡先がありません", "empty_state": "連絡先がありません",
"empty_state_title": "連絡先がありません", "empty_state_title": "連絡先がありません",
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください", "empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
@@ -1625,7 +1628,8 @@
"level_low": "低", "level_low": "低",
"categories": "カテゴリー", "categories": "カテゴリー",
"categories_placeholder": "例:家族、友人、同僚", "categories_placeholder": "例:家族、友人、同僚",
"categories_hint": "カンマで区切ってください", "categories_hint": "検索または追加するには入力",
"category_add": "追加",
"note": "メモ", "note": "メモ",
"note_placeholder": "メモを追加...", "note_placeholder": "メモを追加...",
"gender": "性別", "gender": "性別",
+5 -1
View File
@@ -1482,6 +1482,9 @@
"title": "Contacten", "title": "Contacten",
"search_placeholder": "Contacten zoeken...", "search_placeholder": "Contacten zoeken...",
"create_new": "Nieuw contact", "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": "Geen contacten",
"empty_state_title": "Geen contacten", "empty_state_title": "Geen contacten",
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand", "empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
@@ -1625,7 +1628,8 @@
"level_low": "Laag", "level_low": "Laag",
"categories": "Categorieën", "categories": "Categorieën",
"categories_placeholder": "bijv. Familie, Vrienden, Collega's", "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": "Notities",
"note_placeholder": "Notitie toevoegen...", "note_placeholder": "Notitie toevoegen...",
"gender": "Geslacht", "gender": "Geslacht",
+5 -1
View File
@@ -1482,6 +1482,9 @@
"title": "Contatos", "title": "Contatos",
"search_placeholder": "Pesquisar contatos...", "search_placeholder": "Pesquisar contatos...",
"create_new": "Novo contato", "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": "Nenhum contato",
"empty_state_title": "Sem contatos", "empty_state_title": "Sem contatos",
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard", "empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
@@ -1625,7 +1628,8 @@
"level_low": "Baixo", "level_low": "Baixo",
"categories": "Categorias", "categories": "Categorias",
"categories_placeholder": "ex., Família, Amigos, Colegas", "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": "Notas",
"note_placeholder": "Adicionar uma nota...", "note_placeholder": "Adicionar uma nota...",
"gender": "Gênero", "gender": "Gênero",