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 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
|
||||||
|
|||||||
@@ -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}: "{inputValue.trim()}"
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "性別",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user