"use client"; import { useMemo, useState } from "react"; import { useTranslations, useLocale } from "next-intl"; import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { ContactListItem } from "./contact-list-item"; import { ContactContextMenu } from "./contact-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { cn } from "@/lib/utils"; import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store"; import { useSettingsStore } from "@/stores/settings-store"; type TriState = boolean | null; interface ListFilters { organization: string; jobTitle: string; location: string; emailDomain: string; birthdayMonth: number | null; hasEmail: TriState; hasPhone: TriState; hasPhoto: TriState; } const EMPTY_FILTERS: ListFilters = { organization: "", jobTitle: "", location: "", emailDomain: "", birthdayMonth: null, hasEmail: null, hasPhone: null, hasPhoto: null, }; function cycleTri(v: TriState): TriState { return v === null ? true : v === true ? false : null; } function countActiveFilters(f: ListFilters): number { let n = 0; if (f.organization.trim()) n++; if (f.jobTitle.trim()) n++; if (f.location.trim()) n++; if (f.emailDomain.trim()) n++; if (f.birthdayMonth !== null) n++; if (f.hasEmail !== null) n++; if (f.hasPhone !== null) n++; if (f.hasPhoto !== null) n++; return n; } function matchTri(actual: boolean, filter: TriState): boolean { if (filter === null) return true; return actual === filter; } function getAnniversaryMonth(date: AnniversaryDate): number | null { if (typeof date === "string") { const iso = date.match(/^(\d{4})-(\d{2})/); if (iso) return parseInt(iso[2], 10); const partial = date.match(/^--(\d{2})/); if (partial) return parseInt(partial[1], 10); return null; } if ("month" in date && date.month) return date.month; if ("utc" in date && date.utc) { const d = new Date(date.utc); return isNaN(d.getTime()) ? null : d.getMonth() + 1; } return null; } function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: TriState; onClick: () => void }) { return ( {icon} {label} ); } interface ContactListProps { contacts: ContactCard[]; selectedContactId: string | null; searchQuery: string; onSearchChange: (query: string) => void; onSelectContact: (id: string) => void; onCreateNew: () => void; className?: string; selectedContactIds: Set; onToggleSelection: (id: string) => void; onSelectRangeContacts: (id: string, sortedIds: string[]) => void; onSelectAll: (ids: string[]) => void; onClearSelection: () => void; onBulkDelete: () => void; onBulkAddToGroup: () => void; onBulkExport: () => void; onEditContact: (id: string) => void; onDeleteContact: (contact: ContactCard) => void; onAddContactToGroup: (id: string) => void; /** Show a burger button at the start that opens the (overlay) categories sidebar. */ onMenuClick?: () => void; } export function ContactList({ contacts, selectedContactId, searchQuery, onSearchChange, onSelectContact, onCreateNew, className, selectedContactIds, onToggleSelection, onSelectRangeContacts, onSelectAll, onClearSelection, onBulkDelete, onBulkAddToGroup, onBulkExport, onEditContact, onDeleteContact, onAddContactToGroup, onMenuClick, }: ContactListProps) { const t = useTranslations("contacts"); const locale = useLocale(); const density = useSettingsStore((state) => state.density); const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); const [filtersOpen, setFiltersOpen] = useState(false); const [filters, setFilters] = useState(EMPTY_FILTERS); const activeFilters = countActiveFilters(filters); const monthNames = useMemo(() => { const fmt = new Intl.DateTimeFormat(locale, { month: "long" }); return Array.from({ length: 12 }, (_, i) => fmt.format(new Date(2000, i, 1))); }, [locale]); const filtered = useMemo(() => { const lower = searchQuery.trim().toLowerCase(); const orgLower = filters.organization.trim().toLowerCase(); const jobLower = filters.jobTitle.trim().toLowerCase(); const locLower = filters.location.trim().toLowerCase(); const domainLower = filters.emailDomain.trim().toLowerCase().replace(/^@/, ""); return contacts.filter((c) => { const emails = c.emails ? Object.values(c.emails) : []; const phones = c.phones ? Object.values(c.phones) : []; const orgs = c.organizations ? Object.values(c.organizations) : []; const titles = c.titles ? Object.values(c.titles) : []; const addresses = c.addresses ? Object.values(c.addresses) : []; const anniversaries = c.anniversaries ? Object.values(c.anniversaries) : []; if (!matchTri(emails.length > 0, filters.hasEmail)) return false; if (!matchTri(phones.length > 0, filters.hasPhone)) return false; if (!matchTri(!!getContactPhotoUri(c), filters.hasPhoto)) return false; if (orgLower) { const match = orgs.some((o) => { if (o.name?.toLowerCase().includes(orgLower)) return true; if (o.units?.some((u) => u.name?.toLowerCase().includes(orgLower))) return true; return false; }); if (!match) return false; } if (jobLower) { if (!titles.some((ti) => ti.name?.toLowerCase().includes(jobLower))) return false; } if (locLower) { const match = addresses.some((a) => { const parts: string[] = []; if (a.full) parts.push(a.full); if (a.fullAddress) parts.push(a.fullAddress); if (a.locality) parts.push(a.locality); if (a.region) parts.push(a.region); if (a.country) parts.push(a.country); if (a.postcode) parts.push(a.postcode); if (a.street) parts.push(a.street); if (a.components) { for (const comp of a.components) { if (comp.value) parts.push(comp.value); } } return parts.some((p) => p.toLowerCase().includes(locLower)); }); if (!match) return false; } if (domainLower) { const match = emails.some((e) => { const at = e.address?.toLowerCase().split("@"); return at && at.length > 1 && at[1].includes(domainLower); }); if (!match) return false; } if (filters.birthdayMonth !== null) { const target = filters.birthdayMonth; const match = anniversaries.some((a) => a.kind === "birth" && getAnniversaryMonth(a.date) === target); if (!match) return false; } if (!lower) return true; const name = getContactDisplayName(c).toLowerCase(); if (name.includes(lower)) return true; if (emails.some((e) => e.address?.toLowerCase().includes(lower))) return true; if (phones.some((p) => p.number?.toLowerCase().includes(lower))) return true; if (orgs.some((o) => o.name?.toLowerCase().includes(lower))) return true; return false; }); }, [contacts, searchQuery, filters]); const sorted = useMemo(() => { return [...filtered].sort((a, b) => { const nameA = getContactDisplayName(a).toLowerCase(); const nameB = getContactDisplayName(b).toLowerCase(); return nameA.localeCompare(nameB); }); }, [filtered]); const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]); // Group sorted contacts by first letter of display name. Non-letter // starters (digits, symbols, empty) collect under "#" which sorts last. const groupedSections = useMemo(() => { const collator = new Intl.Collator(locale, { sensitivity: "base" }); const groups = new Map(); for (const contact of sorted) { const name = getContactDisplayName(contact).trim(); const first = name.charAt(0); const letter = first && first.toLocaleUpperCase(locale).match(/\p{L}/u) ? first.toLocaleUpperCase(locale) : "#"; const bucket = groups.get(letter); if (bucket) bucket.push(contact); else groups.set(letter, [contact]); } return Array.from(groups.entries()) .sort(([a], [b]) => { if (a === "#") return 1; if (b === "#") return -1; return collator.compare(a, b); }) .map(([letter, items]) => ({ letter, items })); }, [sorted, locale]); const hasSelection = selectedContactIds.size > 0; const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id)); return ( {/* Toolbar: select / search / filter */} {onMenuClick && ( )} { if (hasSelection) { if (allSelected) onClearSelection(); else onSelectAll(sortedIds); } else if (sortedIds.length > 0) { const target = selectedContactId && sortedIds.includes(selectedContactId) ? selectedContactId : sortedIds[0]; onToggleSelection(target); } }} className={cn( "flex-shrink-0 p-2 rounded-md transition-colors", hasSelection ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted", )} title={hasSelection ? (allSelected ? t("bulk.clear") : t("bulk.select_all")) : t("filters.select")} > {hasSelection ? : } onSearchChange(e.target.value)} className={cn("pl-9 h-9", searchQuery && "pr-8")} /> {searchQuery && ( onSearchChange("")} className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" aria-label={t("clear_search")} > )} setFiltersOpen((v) => !v)} className={cn( "relative flex-shrink-0 p-2 rounded-md transition-colors", filtersOpen || activeFilters > 0 ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted", )} title={t("filters.toggle")} aria-label={t("filters.toggle")} > {!filtersOpen && activeFilters > 0 && ( {activeFilters} )} {filtersOpen && ( {t("filters.title")} setFilters(EMPTY_FILTERS)} className="h-7 px-2 text-xs" > {t("filters.clear")} setFiltersOpen(false)} className="h-7 w-7" aria-label={t("filters.close")} > {t("filters.organization")} setFilters((f) => ({ ...f, organization: e.target.value }))} placeholder={t("filters.organization_placeholder")} className="h-8 text-sm" /> {t("filters.job_title")} setFilters((f) => ({ ...f, jobTitle: e.target.value }))} placeholder={t("filters.job_title_placeholder")} className="h-8 text-sm" /> {t("filters.location")} setFilters((f) => ({ ...f, location: e.target.value }))} placeholder={t("filters.location_placeholder")} className="h-8 text-sm" /> {t("filters.email_domain")} setFilters((f) => ({ ...f, emailDomain: e.target.value }))} placeholder={t("filters.email_domain_placeholder")} className="h-8 text-sm" /> {t("filters.birthday_month")} setFilters((f) => ({ ...f, birthdayMonth: e.target.value === "" ? null : Number(e.target.value) }))} className="h-8 w-full text-sm rounded-md border border-input bg-background px-2" aria-label={t("filters.birthday_month")} > {t("filters.any_month")} {monthNames.map((name, i) => ( {name} ))} } label={t("filters.has_email")} value={filters.hasEmail} onClick={() => setFilters((f) => ({ ...f, hasEmail: cycleTri(f.hasEmail) }))} /> } label={t("filters.has_phone")} value={filters.hasPhone} onClick={() => setFilters((f) => ({ ...f, hasPhone: cycleTri(f.hasPhone) }))} /> } label={t("filters.has_photo")} value={filters.hasPhoto} onClick={() => setFilters((f) => ({ ...f, hasPhoto: cycleTri(f.hasPhoto) }))} /> )} {/* Bulk action bar */} {hasSelection && ( { if (allSelected) { onClearSelection(); } else { onSelectAll(sortedIds); } }} className="p-1 rounded hover:bg-muted/50 transition-colors" > {allSelected ? ( ) : ( )} {t("bulk.selected", { count: selectedContactIds.size })} {t("bulk.add_to_group")} {t("bulk.export")} {t("bulk.delete")} )} {/* Contact list */} {sorted.length === 0 ? ( {searchQuery || activeFilters > 0 ? ( <> {activeFilters > 0 && !searchQuery ? ( ) : ( )} {searchQuery ? t("empty_search") : t("empty_filtered")} {searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")} {searchQuery && ( onSearchChange("")}> {t("clear_search")} )} {activeFilters > 0 && ( setFilters(EMPTY_FILTERS)}> {t("filters.clear")} )} > ) : ( <> {t("empty_state_title")} {t("empty_state_subtitle")} {t("create_new")} > )} ) : ( (() => { const renderItem = (contact: ContactCard) => ( { if (e.ctrlKey || e.metaKey) { e.preventDefault(); onToggleSelection(contact.id); } else if (e.shiftKey) { e.preventDefault(); onSelectRangeContacts(contact.id, sortedIds); } else { if (hasSelection) onClearSelection(); onSelectContact(contact.id); } }} onCheckboxClick={(e) => { e.stopPropagation(); onToggleSelection(contact.id); }} onContextMenu={(e, c) => openContextMenu(e, c)} /> ); return groupByLetter ? ( {groupedSections.map(({ letter, items }) => ( {letter} {items.map(renderItem)} ))} ) : ( {sorted.map(renderItem)} ); })() )} {contextMenu.data && ( onSelectContact(contextMenu.data!.id)} onEdit={() => onEditContact(contextMenu.data!.id)} onDelete={() => onDeleteContact(contextMenu.data!)} onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)} onBatchExport={onBulkExport} onBatchAddToGroup={onBulkAddToGroup} onBatchDelete={onBulkDelete} /> )} ); }
{searchQuery ? t("empty_search") : t("empty_filtered")}
{searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
{t("empty_state_title")}
{t("empty_state_subtitle")}