- P2.2: 'Create Appointment' button in email viewer → pre-fills event modal with subject, body, participants, date. calendar-store newEventPrefill state. - P2.4: MiniCalendarDashlet in sidebar bottom — month grid with event dots, day click navigates to calendar. Collapsible, respect firstDayOfWeek. - P2.12: Custom radial menu (components/ui/radial-menu.tsx) — circular SVG menu with keyboard nav, animations. Wired into email-list, contact-list, file-browser, calendar-month-view right-click handlers. - P2.14: 'Send as Attachment' button in file browser — opens compose tab with selected files pre-attached via Pro tab store.
682 lines
26 KiB
TypeScript
682 lines
26 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState, useCallback } 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, Pencil } 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 { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-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 (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors border",
|
|
value === true && "bg-primary/10 border-primary/30 text-primary",
|
|
value === false && "bg-muted border-border text-muted-foreground line-through",
|
|
value === null && "bg-background border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground",
|
|
)}
|
|
>
|
|
{icon}
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface ContactListProps {
|
|
contacts: ContactCard[];
|
|
selectedContactId: string | null;
|
|
searchQuery: string;
|
|
onSearchChange: (query: string) => void;
|
|
onSelectContact: (id: string) => void;
|
|
onCreateNew: () => void;
|
|
className?: string;
|
|
selectedContactIds: Set<string>;
|
|
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<ContactCard>();
|
|
|
|
// Radial menu state
|
|
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
|
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
|
const [radialMenuContact, setRadialMenuContact] = useState<ContactCard | null>(null);
|
|
|
|
const openRadialMenu = useCallback((e: React.MouseEvent, contact: ContactCard) => {
|
|
e.preventDefault();
|
|
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
|
setRadialMenuContact(contact);
|
|
setRadialMenuOpen(true);
|
|
}, []);
|
|
|
|
const closeRadialMenu = useCallback(() => {
|
|
setRadialMenuOpen(false);
|
|
}, []);
|
|
|
|
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
|
if (!radialMenuContact) return [];
|
|
const c = radialMenuContact;
|
|
const items: RadialMenuItem[] = [];
|
|
items.push({
|
|
id: "edit",
|
|
icon: <Pencil className="w-5 h-5" />,
|
|
label: t("edit"),
|
|
onClick: () => { onEditContact(c.id); },
|
|
});
|
|
items.push({
|
|
id: "delete",
|
|
icon: <Trash2 className="w-5 h-5" />,
|
|
label: t("delete"),
|
|
onClick: () => { onDeleteContact(c); },
|
|
destructive: true,
|
|
});
|
|
if (c.emails && Object.keys(c.emails).length > 0) {
|
|
const contactEmails = c.emails;
|
|
items.push({
|
|
id: "send-email",
|
|
icon: <Mail className="w-5 h-5" />,
|
|
label: t("send_email"),
|
|
onClick: () => {
|
|
const values = Object.values(contactEmails);
|
|
if (values[0]?.address) {
|
|
window.location.href = `mailto:${values[0].address}`;
|
|
}
|
|
},
|
|
});
|
|
}
|
|
items.push({
|
|
id: "export",
|
|
icon: <Download className="w-5 h-5" />,
|
|
label: t("export"),
|
|
onClick: () => { onBulkExport(); },
|
|
});
|
|
return items;
|
|
}, [radialMenuContact, t, onEditContact, onDeleteContact, onBulkExport]);
|
|
|
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
const [filters, setFilters] = useState<ListFilters>(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<string, ContactCard[]>();
|
|
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 (
|
|
<div className={cn("flex flex-col h-full", className)}>
|
|
{/* Toolbar: select / search / filter */}
|
|
<div className="border-b border-border bg-background">
|
|
<div className="px-3 py-3">
|
|
<div className="flex items-center gap-1.5">
|
|
{onMenuClick && (
|
|
<button
|
|
type="button"
|
|
onClick={onMenuClick}
|
|
className="flex-shrink-0 p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
aria-label={t("open_categories")}
|
|
>
|
|
<Menu className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
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 ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
|
</button>
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input
|
|
type="text"
|
|
placeholder={t("search_placeholder")}
|
|
value={searchQuery}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className={cn("ps-9 h-9", searchQuery && "pe-8")}
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
type="button"
|
|
onClick={() => 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")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => 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")}
|
|
>
|
|
<Filter className="w-4 h-4" />
|
|
{!filtersOpen && activeFilters > 0 && (
|
|
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
|
|
{activeFilters}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{filtersOpen && (
|
|
<div className="border-b border-border bg-muted/30 animate-in slide-in-from-top-2 fade-in duration-200">
|
|
<div className="px-4 py-3 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-foreground">{t("filters.title")}</span>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setFilters(EMPTY_FILTERS)}
|
|
className="h-7 px-2 text-xs"
|
|
>
|
|
<RotateCcw className="w-3 h-3 me-1" />
|
|
{t("filters.clear")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setFiltersOpen(false)}
|
|
className="h-7 w-7"
|
|
aria-label={t("filters.close")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.organization")}</label>
|
|
<Input
|
|
value={filters.organization}
|
|
onChange={(e) => setFilters((f) => ({ ...f, organization: e.target.value }))}
|
|
placeholder={t("filters.organization_placeholder")}
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.job_title")}</label>
|
|
<Input
|
|
value={filters.jobTitle}
|
|
onChange={(e) => setFilters((f) => ({ ...f, jobTitle: e.target.value }))}
|
|
placeholder={t("filters.job_title_placeholder")}
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.location")}</label>
|
|
<Input
|
|
value={filters.location}
|
|
onChange={(e) => setFilters((f) => ({ ...f, location: e.target.value }))}
|
|
placeholder={t("filters.location_placeholder")}
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.email_domain")}</label>
|
|
<Input
|
|
value={filters.emailDomain}
|
|
onChange={(e) => setFilters((f) => ({ ...f, emailDomain: e.target.value }))}
|
|
placeholder={t("filters.email_domain_placeholder")}
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs text-muted-foreground mb-1 block">{t("filters.birthday_month")}</label>
|
|
<select
|
|
value={filters.birthdayMonth ?? ""}
|
|
onChange={(e) => 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")}
|
|
>
|
|
<option value="">{t("filters.any_month")}</option>
|
|
{monthNames.map((name, i) => (
|
|
<option key={i} value={i + 1}>{name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<ToggleChip
|
|
icon={<Mail className="w-3.5 h-3.5" />}
|
|
label={t("filters.has_email")}
|
|
value={filters.hasEmail}
|
|
onClick={() => setFilters((f) => ({ ...f, hasEmail: cycleTri(f.hasEmail) }))}
|
|
/>
|
|
<ToggleChip
|
|
icon={<Phone className="w-3.5 h-3.5" />}
|
|
label={t("filters.has_phone")}
|
|
value={filters.hasPhone}
|
|
onClick={() => setFilters((f) => ({ ...f, hasPhone: cycleTri(f.hasPhone) }))}
|
|
/>
|
|
<ToggleChip
|
|
icon={<ImageIcon className="w-3.5 h-3.5" />}
|
|
label={t("filters.has_photo")}
|
|
value={filters.hasPhoto}
|
|
onClick={() => setFilters((f) => ({ ...f, hasPhoto: cycleTri(f.hasPhoto) }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bulk action bar */}
|
|
{hasSelection && (
|
|
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
|
|
<button
|
|
onClick={() => {
|
|
if (allSelected) {
|
|
onClearSelection();
|
|
} else {
|
|
onSelectAll(sortedIds);
|
|
}
|
|
}}
|
|
className="p-1 rounded hover:bg-muted/50 transition-colors"
|
|
>
|
|
{allSelected ? (
|
|
<CheckSquare className="w-4 h-4 text-primary" />
|
|
) : (
|
|
<Square className="w-4 h-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
<span className="text-xs font-medium text-foreground">
|
|
{t("bulk.selected", { count: selectedContactIds.size })}
|
|
</span>
|
|
<div className="flex-1" />
|
|
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
|
|
<Users className="w-3.5 h-3.5 me-1" />
|
|
{t("bulk.add_to_group")}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={onBulkExport} className="h-7 text-xs">
|
|
<Download className="w-3.5 h-3.5 me-1" />
|
|
{t("bulk.export")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onBulkDelete}
|
|
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5 me-1" />
|
|
{t("bulk.delete")}
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
|
|
<X className="w-3.5 h-3.5" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Contact list */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{sorted.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
|
|
{searchQuery || activeFilters > 0 ? (
|
|
<>
|
|
{activeFilters > 0 && !searchQuery ? (
|
|
<Filter className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
|
) : (
|
|
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
|
)}
|
|
<p className="text-sm font-medium text-foreground">
|
|
{searchQuery ? t("empty_search") : t("empty_filtered")}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
|
|
</p>
|
|
<div className="flex gap-2 mt-3">
|
|
{searchQuery && (
|
|
<Button variant="outline" size="sm" onClick={() => onSearchChange("")}>
|
|
{t("clear_search")}
|
|
</Button>
|
|
)}
|
|
{activeFilters > 0 && (
|
|
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
|
|
<RotateCcw className="w-3.5 h-3.5 me-1" />
|
|
{t("filters.clear")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<BookUser className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
|
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
|
|
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
|
|
<Button size="sm" className="mt-3" onClick={onCreateNew}>
|
|
<UserPlus className="w-4 h-4 me-1.5" />
|
|
{t("create_new")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
(() => {
|
|
const renderItem = (contact: ContactCard) => (
|
|
<ContactListItem
|
|
key={contact.id}
|
|
contact={contact}
|
|
isSelected={contact.id === selectedContactId}
|
|
isChecked={selectedContactIds.has(contact.id)}
|
|
hasSelection={hasSelection}
|
|
density={density}
|
|
selectedContactIds={selectedContactIds}
|
|
onClick={(e) => {
|
|
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); openRadialMenu(e, c); }}
|
|
/>
|
|
);
|
|
return groupByLetter ? (
|
|
<div>
|
|
{groupedSections.map(({ letter, items }) => (
|
|
<section key={letter}>
|
|
<div className="sticky top-0 z-10 px-4 py-0.5 bg-background/90 backdrop-blur-sm text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
|
|
{letter}
|
|
</div>
|
|
{items.map(renderItem)}
|
|
</section>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div>{sorted.map(renderItem)}</div>
|
|
);
|
|
})()
|
|
)}
|
|
</div>
|
|
|
|
{/* Radial Action Menu */}
|
|
<RadialMenu
|
|
items={radialMenuItems}
|
|
isOpen={radialMenuOpen}
|
|
position={radialMenuPos}
|
|
onClose={closeRadialMenu}
|
|
/>
|
|
|
|
{contextMenu.data && (
|
|
<ContactContextMenu
|
|
contact={contextMenu.data}
|
|
position={contextMenu.position}
|
|
isOpen={contextMenu.isOpen}
|
|
onClose={closeContextMenu}
|
|
menuRef={menuRef}
|
|
isMultiSelect={selectedContactIds.has(contextMenu.data.id)}
|
|
selectedCount={selectedContactIds.size}
|
|
onOpen={() => onSelectContact(contextMenu.data!.id)}
|
|
onEdit={() => onEditContact(contextMenu.data!.id)}
|
|
onDelete={() => onDeleteContact(contextMenu.data!)}
|
|
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
|
|
onBatchExport={onBulkExport}
|
|
onBatchAddToGroup={onBulkAddToGroup}
|
|
onBatchDelete={onBulkDelete}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|