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:
@@ -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<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
|
||||
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 <ContactForm addressBooks={addressBooks} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
|
||||
case "edit":
|
||||
if (!selectedContact) return null;
|
||||
@@ -438,6 +479,7 @@ export default function ContactsPage() {
|
||||
<ContactForm
|
||||
contact={selectedContact}
|
||||
addressBooks={addressBooks}
|
||||
allKeywords={allKeywords}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
@@ -590,6 +632,7 @@ export default function ContactsPage() {
|
||||
onEditGroup={handleEditGroupFromSidebar}
|
||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useCallback, useEffect, 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 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -48,6 +48,7 @@ interface AddressEntry {
|
||||
interface ContactFormProps {
|
||||
contact?: ContactCard | null;
|
||||
addressBooks?: AddressBook[];
|
||||
allKeywords?: string[];
|
||||
onSave: (data: Partial<ContactCard>) => Promise<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 isEditing = !!contact;
|
||||
|
||||
@@ -819,14 +820,14 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
||||
|
||||
{/* Categories */}
|
||||
<FormSection icon={Tag} title={t("categories")} collapsible defaultOpen category="digital">
|
||||
<div>
|
||||
<Input
|
||||
value={keywordsStr}
|
||||
onChange={(e) => setKeywordsStr(e.target.value)}
|
||||
placeholder={t("categories_placeholder")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">{t("categories_hint")}</p>
|
||||
</div>
|
||||
<CategoryComboBox
|
||||
keywordsStr={keywordsStr}
|
||||
onChange={setKeywordsStr}
|
||||
allKeywords={allKeywords || []}
|
||||
placeholder={t("categories_placeholder")}
|
||||
hint={t("categories_hint")}
|
||||
addLabel={t("category_add")}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Gender */}
|
||||
@@ -895,3 +896,142 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
||||
</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}: "{inputValue.trim()}"
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
@@ -311,46 +318,56 @@ export function ContactsSidebar({
|
||||
)}
|
||||
|
||||
{/* Categories section (from contact keywords) */}
|
||||
{allKeywords.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleSection("categories")}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
>
|
||||
{collapsed.categories ? (
|
||||
<ChevronRight 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">
|
||||
{t("detail.categories")}
|
||||
</span>
|
||||
</button>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleSection("categories")}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
>
|
||||
{collapsed.categories ? (
|
||||
<ChevronRight 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">
|
||||
{t("detail.categories")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!collapsed.categories && allKeywords.map(([keyword, count]) => {
|
||||
const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword;
|
||||
return (
|
||||
<button
|
||||
key={keyword}
|
||||
onClick={() => onSelectCategory({ keyword })}
|
||||
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"
|
||||
)}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!collapsed.categories && (
|
||||
<>
|
||||
{/* No Category item */}
|
||||
<button
|
||||
onClick={() => onSelectCategory("uncategorized")}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||
activeCategory === "uncategorized"
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted"
|
||||
)}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
|
||||
<span className="truncate italic">{t("no_category")}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
{uncategorizedCount}
|
||||
</span>
|
||||
</button>
|
||||
{allKeywords.map(([keyword, count]) => {
|
||||
const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword;
|
||||
return (
|
||||
<CategoryItem
|
||||
key={keyword}
|
||||
keyword={keyword}
|
||||
count={count}
|
||||
isActive={isActive}
|
||||
onSelect={() => onSelectCategory({ keyword })}
|
||||
onDropContacts={onDropContactsToCategory}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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<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({
|
||||
book,
|
||||
isActive,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "性別",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user