feat: add contacts phase 2, advanced search, vacation responder, Docker & TOTP 2FA
- Contact groups/lists, vCard import/export (RFC 6350), bulk operations - Advanced search with JMAP filter panel, search chips, cross-mailbox queries - Vacation responder with JMAP VacationResponse, settings tab, sidebar indicator - TOTP two-factor authentication support - Docker multi-stage build with standalone output and docker-compose - CSP Report-Only headers and security headers via proxy middleware - Virtual scrolling for large email lists - Structured server-side logger (text/JSON, configurable level) - 450+ tests (contacts, vCard, threads, headers, identity, components) - Playwright E2E framework setup - Updated README and ROADMAP with all new features
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ContactDetail } from '../contact-detail';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
const contact: ContactCard = {
|
||||
id: '1',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
phones: { p0: { number: '+33612345678' } },
|
||||
organizations: { o0: { name: 'Acme Corp' } },
|
||||
addresses: { a0: { street: '123 Main St', locality: 'Paris', country: 'France' } },
|
||||
notes: { n0: { note: 'VIP customer' } },
|
||||
};
|
||||
|
||||
describe('ContactDetail', () => {
|
||||
it('shows empty state when contact is null', () => {
|
||||
render(<ContactDetail contact={null} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
expect(screen.getByText('detail.no_contact_selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the contact name', () => {
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays email addresses as mailto links', () => {
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
const link = screen.getByText('alice@example.com');
|
||||
expect(link.closest('a')).toHaveAttribute('href', 'mailto:alice@example.com');
|
||||
});
|
||||
|
||||
it('displays phone numbers', () => {
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
expect(screen.getByText('+33612345678')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays organization name', () => {
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
const matches = screen.getAllByText('Acme Corp');
|
||||
expect(matches.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('calls onEdit when edit button is clicked', () => {
|
||||
const onEdit = vi.fn();
|
||||
render(<ContactDetail contact={contact} onEdit={onEdit} onDelete={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText('form.edit_title'));
|
||||
expect(onEdit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onDelete when delete button is clicked', () => {
|
||||
const onDelete = vi.fn();
|
||||
render(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={onDelete} />);
|
||||
const trashButtons = screen.getAllByRole('button').filter(
|
||||
btn => btn.querySelector('svg') && btn.textContent?.trim() === ''
|
||||
);
|
||||
fireEvent.click(trashButtons[trashButtons.length - 1]);
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ContactForm } from '../contact-form';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
const existingContact: ContactCard = {
|
||||
id: '1',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
phones: { p0: { number: '+33612345678' } },
|
||||
organizations: { o0: { name: 'Acme Corp' } },
|
||||
notes: { n0: { note: 'VIP' } },
|
||||
};
|
||||
|
||||
describe('ContactForm', () => {
|
||||
it('renders create form with empty fields', () => {
|
||||
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(screen.getByText('create_title')).toBeInTheDocument();
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
const emptyInputs = inputs.filter(i => (i as HTMLInputElement).value === '');
|
||||
expect(emptyInputs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders edit form with pre-populated data', () => {
|
||||
render(<ContactForm contact={existingContact} onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(screen.getByText('edit_title')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Smith')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCancel when cancel button is clicked', () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<ContactForm onSave={vi.fn()} onCancel={onCancel} />);
|
||||
fireEvent.click(screen.getByText('cancel'));
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows error on submit with empty name', async () => {
|
||||
const onSave = vi.fn();
|
||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('name_required')).toBeInTheDocument();
|
||||
});
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds email entry when add button is clicked', () => {
|
||||
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
const emailInputsBefore = screen.getAllByPlaceholderText('email_placeholder');
|
||||
fireEvent.click(screen.getByText('add_email'));
|
||||
const emailInputsAfter = screen.getAllByPlaceholderText('email_placeholder');
|
||||
expect(emailInputsAfter.length).toBe(emailInputsBefore.length + 1);
|
||||
});
|
||||
|
||||
it('adds phone entry when add button is clicked', () => {
|
||||
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
const phoneBefore = screen.queryAllByPlaceholderText('phone_placeholder');
|
||||
fireEvent.click(screen.getByText('add_phone'));
|
||||
const phoneAfter = screen.getAllByPlaceholderText('phone_placeholder');
|
||||
expect(phoneAfter.length).toBe(phoneBefore.length + 1);
|
||||
});
|
||||
|
||||
it('submits form data correctly', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
fireEvent.change(inputs[0], { target: { value: 'Jane' } });
|
||||
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const savedData = onSave.mock.calls[0][0];
|
||||
expect(savedData.name.components).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ kind: 'given', value: 'Jane' })])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ContactListItem } from '../contact-list-item';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
const contact: ContactCard = {
|
||||
id: '1',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
organizations: { o0: { name: 'Acme Corp' } },
|
||||
};
|
||||
|
||||
const noNameContact: ContactCard = {
|
||||
id: '2',
|
||||
addressBookIds: {},
|
||||
emails: { e0: { address: 'nobody@example.com' } },
|
||||
};
|
||||
|
||||
const _emptyContact: ContactCard = {
|
||||
id: '3',
|
||||
addressBookIds: {},
|
||||
};
|
||||
|
||||
describe('ContactListItem', () => {
|
||||
it('renders contact name and email', () => {
|
||||
render(<ContactListItem contact={contact} isSelected={false} onClick={vi.fn()} />);
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders organization', () => {
|
||||
render(<ContactListItem contact={contact} isSelected={false} onClick={vi.fn()} />);
|
||||
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies selected styling', () => {
|
||||
const { container } = render(<ContactListItem contact={contact} isSelected={true} onClick={vi.fn()} />);
|
||||
const button = container.querySelector('button');
|
||||
expect(button?.className).toContain('bg-accent');
|
||||
});
|
||||
|
||||
it('shows email as display name when no name exists', () => {
|
||||
render(<ContactListItem contact={noNameContact} isSelected={false} onClick={vi.fn()} />);
|
||||
const matches = screen.getAllByText('nobody@example.com');
|
||||
expect(matches.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('calls onClick when clicked', () => {
|
||||
const onClick = vi.fn();
|
||||
render(<ContactListItem contact={contact} isSelected={false} onClick={onClick} />);
|
||||
fireEvent.click(screen.getByText('Alice Smith'));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ContactList } from '../contact-list';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
function makeContact(overrides: Partial<ContactCard> & { id: string }): ContactCard {
|
||||
return {
|
||||
addressBookIds: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const alice = makeContact({
|
||||
id: '1',
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
});
|
||||
|
||||
const bob = makeContact({
|
||||
id: '2',
|
||||
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
|
||||
emails: { e0: { address: 'bob@example.com' } },
|
||||
});
|
||||
|
||||
const group = makeContact({
|
||||
id: '3',
|
||||
kind: 'group',
|
||||
name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true },
|
||||
members: { '1': true },
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
contacts: [alice, bob],
|
||||
selectedContactId: null,
|
||||
searchQuery: '',
|
||||
onSearchChange: vi.fn(),
|
||||
onSelectContact: vi.fn(),
|
||||
onCreateNew: vi.fn(),
|
||||
supportsSync: true,
|
||||
selectedContactIds: new Set<string>(),
|
||||
onToggleSelection: vi.fn(),
|
||||
onSelectAll: vi.fn(),
|
||||
onClearSelection: vi.fn(),
|
||||
onBulkDelete: vi.fn(),
|
||||
onBulkAddToGroup: vi.fn(),
|
||||
onBulkExport: vi.fn(),
|
||||
groups: [],
|
||||
};
|
||||
|
||||
describe('ContactList', () => {
|
||||
it('renders contact names', () => {
|
||||
render(<ContactList {...defaultProps} />);
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob Jones')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters contacts by search query', () => {
|
||||
render(<ContactList {...defaultProps} searchQuery="alice" />);
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Bob Jones')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no contacts match', () => {
|
||||
render(<ContactList {...defaultProps} contacts={[]} />);
|
||||
expect(screen.getByText('empty_state')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows search empty state when search has no results', () => {
|
||||
render(<ContactList {...defaultProps} searchQuery="zzz" />);
|
||||
expect(screen.getByText('empty_search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows local mode banner when supportsSync is false', () => {
|
||||
render(<ContactList {...defaultProps} supportsSync={false} />);
|
||||
expect(screen.getByText('local_mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides local mode banner when supportsSync is true', () => {
|
||||
render(<ContactList {...defaultProps} supportsSync={true} />);
|
||||
expect(screen.queryByText('local_mode')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCreateNew when create button is clicked', () => {
|
||||
const onCreateNew = vi.fn();
|
||||
render(<ContactList {...defaultProps} onCreateNew={onCreateNew} />);
|
||||
fireEvent.click(screen.getByText('create_new'));
|
||||
expect(onCreateNew).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows bulk action bar when contacts are selected', () => {
|
||||
render(<ContactList {...defaultProps} selectedContactIds={new Set(['1'])} />);
|
||||
expect(screen.getByText('bulk.delete')).toBeInTheDocument();
|
||||
expect(screen.getByText('bulk.export')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes groups from the list', () => {
|
||||
render(<ContactList {...defaultProps} contacts={[alice, bob, group]} />);
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Team')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { generateVCard } from "@/lib/vcard";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName } from "@/stores/contact-store";
|
||||
|
||||
export function exportContact(contact: ContactCard) {
|
||||
const vcf = generateVCard([contact]);
|
||||
const name = getContactDisplayName(contact) || "contact";
|
||||
downloadVcf(vcf, `${sanitizeFilename(name)}.vcf`);
|
||||
}
|
||||
|
||||
export function exportContacts(contacts: ContactCard[]) {
|
||||
const vcf = generateVCard(contacts);
|
||||
downloadVcf(vcf, `contacts-${new Date().toISOString().slice(0, 10)}.vcf`);
|
||||
}
|
||||
|
||||
function downloadVcf(content: string, filename: string) {
|
||||
const blob = new Blob([content], { type: "text/vcard;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, "_").substring(0, 50);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Users, Pencil, Trash2, UserMinus } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
|
||||
interface ContactGroupDetailProps {
|
||||
group: ContactCard;
|
||||
members: ContactCard[];
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onRemoveMember: (memberId: string) => void;
|
||||
onSelectMember: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ContactGroupDetail({
|
||||
group,
|
||||
members,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRemoveMember,
|
||||
onSelectMember,
|
||||
className,
|
||||
}: ContactGroupDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const groupName = getContactDisplayName(group);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||
<div className="px-6 py-6 border-b border-border">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Users className="w-7 h-7 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{groupName}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("groups.member_count", { count: members.length })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
<Pencil className="w-4 h-4 mr-1" />
|
||||
{t("form.edit_title")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-3">
|
||||
{t("groups.members_label")}
|
||||
</h3>
|
||||
{members.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("groups.no_members")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{members.map((member) => {
|
||||
const mName = getContactDisplayName(member);
|
||||
const mEmail = getContactPrimaryEmail(member);
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted group transition-colors"
|
||||
>
|
||||
<button
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left"
|
||||
onClick={() => onSelectMember(member.id)}
|
||||
>
|
||||
<Avatar name={mName} email={mEmail} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{mName}</div>
|
||||
{mEmail && (
|
||||
<div className="text-xs text-muted-foreground truncate">{mEmail}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => onRemoveMember(member.id)}
|
||||
>
|
||||
<UserMinus className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Search, Check, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
|
||||
interface ContactGroupFormProps {
|
||||
group?: ContactCard | null;
|
||||
individuals: ContactCard[];
|
||||
currentMemberIds?: string[];
|
||||
onSave: (name: string, memberIds: string[]) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ContactGroupForm({
|
||||
group,
|
||||
individuals,
|
||||
currentMemberIds = [],
|
||||
onSave,
|
||||
onCancel,
|
||||
}: ContactGroupFormProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const isEditing = !!group;
|
||||
|
||||
const [name, setName] = useState(
|
||||
group ? getContactDisplayName(group) : ""
|
||||
);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||||
new Set(currentMemberIds)
|
||||
);
|
||||
const [memberSearch, setMemberSearch] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const filteredIndividuals = useMemo(() => {
|
||||
if (!memberSearch) return individuals;
|
||||
const lower = memberSearch.toLowerCase();
|
||||
return individuals.filter((c) => {
|
||||
const n = getContactDisplayName(c).toLowerCase();
|
||||
const e = getContactPrimaryEmail(c).toLowerCase();
|
||||
return n.includes(lower) || e.includes(lower);
|
||||
});
|
||||
}, [individuals, memberSearch]);
|
||||
|
||||
const toggleMember = (id: string) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!name.trim()) {
|
||||
setError(t("groups.name_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(name.trim(), Array.from(selectedIds));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("groups.save_failed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEditing ? t("groups.edit") : t("groups.create")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground mb-1 block">
|
||||
{t("groups.name_label")}
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("groups.name_placeholder")}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground mb-2 block">
|
||||
{t("groups.members_label")} ({selectedIds.size})
|
||||
</label>
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("groups.search_members")}
|
||||
value={memberSearch}
|
||||
onChange={(e) => setMemberSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md max-h-64 overflow-y-auto divide-y divide-border">
|
||||
{filteredIndividuals.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
|
||||
{t("empty_search")}
|
||||
</div>
|
||||
) : (
|
||||
filteredIndividuals.map((contact) => {
|
||||
const cName = getContactDisplayName(contact);
|
||||
const cEmail = getContactPrimaryEmail(contact);
|
||||
const isSelected = selectedIds.has(contact.id);
|
||||
return (
|
||||
<button
|
||||
key={contact.id}
|
||||
type="button"
|
||||
onClick={() => toggleMember(contact.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors",
|
||||
"hover:bg-muted",
|
||||
isSelected && "bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-5 h-5 rounded border flex items-center justify-center flex-shrink-0 transition-colors",
|
||||
isSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "border-border"
|
||||
)}>
|
||||
{isSelected && <Check className="w-3 h-3" />}
|
||||
</div>
|
||||
<Avatar name={cName} email={cEmail} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{cName}</div>
|
||||
{cEmail && (
|
||||
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Array.from(selectedIds).map((id) => {
|
||||
const contact = individuals.find((c) => c.id === id);
|
||||
if (!contact) return null;
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-primary/10 text-primary"
|
||||
>
|
||||
{getContactDisplayName(contact)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMember(id)}
|
||||
className="hover:text-red-600 dark:hover:text-red-400"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSaving}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (isEditing ? t("form.updating") : t("form.creating")) : t("form.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Users, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName } from "@/stores/contact-store";
|
||||
|
||||
interface ContactGroupListProps {
|
||||
groups: ContactCard[];
|
||||
selectedGroupId: string | null;
|
||||
onSelectGroup: (id: string) => void;
|
||||
onCreateGroup: () => void;
|
||||
searchQuery: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ContactGroupList({
|
||||
groups,
|
||||
selectedGroupId,
|
||||
onSelectGroup,
|
||||
onCreateGroup,
|
||||
searchQuery,
|
||||
className,
|
||||
}: ContactGroupListProps) {
|
||||
const t = useTranslations("contacts");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchQuery) return groups;
|
||||
const lower = searchQuery.toLowerCase();
|
||||
return groups.filter((g) =>
|
||||
getContactDisplayName(g).toLowerCase().includes(lower)
|
||||
);
|
||||
}, [groups, searchQuery]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...filtered].sort((a, b) =>
|
||||
getContactDisplayName(a).localeCompare(getContactDisplayName(b))
|
||||
);
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<Button size="sm" variant="outline" onClick={onCreateGroup} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{t("groups.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sorted.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground px-4">
|
||||
<Users className="w-10 h-10 mb-3 opacity-30" />
|
||||
<p className="text-sm">
|
||||
{searchQuery ? t("empty_search") : t("groups.empty")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{sorted.map((group) => {
|
||||
const memberCount = group.members
|
||||
? Object.values(group.members).filter(Boolean).length
|
||||
: 0;
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
onClick={() => onSelectGroup(group.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-4 py-3 text-left transition-colors",
|
||||
"hover:bg-muted",
|
||||
group.id === selectedGroupId && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{getContactDisplayName(group)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("groups.member_count", { count: memberCount })}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FileText, AlertTriangle, X, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseVCard, detectDuplicates } from "@/lib/vcard";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
|
||||
interface ContactImportDialogProps {
|
||||
existingContacts: ContactCard[];
|
||||
onImport: (contacts: ContactCard[]) => Promise<number>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ContactImportDialog({
|
||||
existingContacts,
|
||||
onImport,
|
||||
onClose,
|
||||
}: ContactImportDialogProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [parsed, setParsed] = useState<ContactCard[]>([]);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [duplicates, setDuplicates] = useState<Map<number, string>>(new Map());
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError(t("import.file_too_large"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const contacts = parseVCard(text);
|
||||
|
||||
if (contacts.length === 0) {
|
||||
setError(t("import.no_contacts"));
|
||||
return;
|
||||
}
|
||||
|
||||
const dupes = detectDuplicates(existingContacts, contacts);
|
||||
setParsed(contacts);
|
||||
setDuplicates(dupes);
|
||||
|
||||
const initialSelected = new Set<number>();
|
||||
contacts.forEach((_, idx) => {
|
||||
if (!dupes.has(idx)) initialSelected.add(idx);
|
||||
});
|
||||
setSelected(initialSelected);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse vCard:', error);
|
||||
setError(t("import.parse_error"));
|
||||
}
|
||||
}, [existingContacts, t]);
|
||||
|
||||
const toggleSelect = (idx: number) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(idx)) {
|
||||
next.delete(idx);
|
||||
} else {
|
||||
next.add(idx);
|
||||
}
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelected(new Set(parsed.map((_, i) => i)));
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
const toImport = parsed.filter((_, i) => selected.has(i));
|
||||
if (toImport.length === 0) return;
|
||||
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const count = await onImport(toImport);
|
||||
setResult(count);
|
||||
} catch (error) {
|
||||
console.error('Failed to import contacts:', error);
|
||||
setError(t("import.failed"));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("import.title")}</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{result !== null ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mb-4">
|
||||
<Check className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">{t("import.success", { count: result })}</p>
|
||||
<Button variant="outline" size="sm" onClick={onClose} className="mt-4">
|
||||
{t("import.close")}
|
||||
</Button>
|
||||
</div>
|
||||
) : parsed.length === 0 ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".vcf,.vcard"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className={cn(
|
||||
"w-full border-2 border-dashed rounded-lg py-12 px-4",
|
||||
"flex flex-col items-center gap-3 transition-colors",
|
||||
"hover:border-primary hover:bg-primary/5",
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Upload className="w-8 h-8" />
|
||||
<p className="text-sm font-medium">{t("import.drop_hint")}</p>
|
||||
<p className="text-xs">{t("import.file_types")}</p>
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("import.found", { count: parsed.length })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={selectAll}>
|
||||
{t("import.select_all")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={deselectAll}>
|
||||
{t("import.deselect_all")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md divide-y divide-border max-h-96 overflow-y-auto">
|
||||
{parsed.map((contact, idx) => {
|
||||
const cName = getContactDisplayName(contact);
|
||||
const cEmail = getContactPrimaryEmail(contact);
|
||||
const isDupe = duplicates.has(idx);
|
||||
const isSelected = selected.has(idx);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => toggleSelect(idx)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted",
|
||||
isSelected && "bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-5 h-5 rounded border flex items-center justify-center flex-shrink-0 transition-colors",
|
||||
isSelected ? "bg-primary border-primary text-primary-foreground" : "border-border"
|
||||
)}>
|
||||
{isSelected && <Check className="w-3 h-3" />}
|
||||
</div>
|
||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{cName || cEmail || "—"}</div>
|
||||
{cEmail && cName && (
|
||||
<div className="text-xs text-muted-foreground truncate">{cEmail}</div>
|
||||
)}
|
||||
</div>
|
||||
{isDupe && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-400 flex-shrink-0">
|
||||
{t("import.duplicate")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parsed.length > 0 && result === null && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("import.selected", { count: selected.size })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={isImporting}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
|
||||
{isImporting ? t("import.importing") : t("import.import_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Search, Plus, BookUser, Info } from "lucide-react";
|
||||
import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
@@ -19,6 +19,13 @@ interface ContactListProps {
|
||||
onCreateNew: () => void;
|
||||
supportsSync: boolean;
|
||||
className?: string;
|
||||
selectedContactIds: Set<string>;
|
||||
onToggleSelection: (id: string) => void;
|
||||
onSelectAll: (ids: string[]) => void;
|
||||
onClearSelection: () => void;
|
||||
onBulkDelete: () => void;
|
||||
onBulkAddToGroup: () => void;
|
||||
onBulkExport: () => void;
|
||||
}
|
||||
|
||||
export function ContactList({
|
||||
@@ -30,13 +37,21 @@ export function ContactList({
|
||||
onCreateNew,
|
||||
supportsSync,
|
||||
className,
|
||||
selectedContactIds,
|
||||
onToggleSelection,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
onBulkDelete,
|
||||
onBulkAddToGroup,
|
||||
onBulkExport,
|
||||
}: ContactListProps) {
|
||||
const t = useTranslations("contacts");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchQuery) return contacts;
|
||||
const individuals = contacts.filter(c => c.kind !== "group");
|
||||
if (!searchQuery) return individuals;
|
||||
const lower = searchQuery.toLowerCase();
|
||||
return contacts.filter((c) => {
|
||||
return individuals.filter((c) => {
|
||||
const name = getContactDisplayName(c).toLowerCase();
|
||||
const emails = c.emails
|
||||
? Object.values(c.emails).map((e) => e.address.toLowerCase())
|
||||
@@ -55,6 +70,9 @@ export function ContactList({
|
||||
});
|
||||
}, [filtered]);
|
||||
|
||||
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)}>
|
||||
<div className="px-4 py-3 border-b border-border space-y-3">
|
||||
@@ -84,6 +102,60 @@ export function ContactList({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasSelection && (
|
||||
<div className="px-3 py-2 border-b border-border bg-muted/50 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-muted-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 mr-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 mr-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 mr-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>
|
||||
)}
|
||||
|
||||
{sorted.length > 0 && (
|
||||
<div className="px-4 py-1.5 border-b border-border flex items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
onClearSelection();
|
||||
} else {
|
||||
onSelectAll(sorted.map(c => c.id));
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center transition-colors",
|
||||
allSelected
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "border-border"
|
||||
)}>
|
||||
{allSelected && <Check className="w-2.5 h-2.5" />}
|
||||
</div>
|
||||
{t("bulk.select_all")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sorted.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground px-4">
|
||||
@@ -95,12 +167,31 @@ export function ContactList({
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{sorted.map((contact) => (
|
||||
<ContactListItem
|
||||
key={contact.id}
|
||||
contact={contact}
|
||||
isSelected={contact.id === selectedContactId}
|
||||
onClick={() => onSelectContact(contact.id)}
|
||||
/>
|
||||
<div key={contact.id} className="flex items-center">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelection(contact.id);
|
||||
}}
|
||||
className="pl-4 pr-1 py-3 flex-shrink-0"
|
||||
>
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center transition-colors",
|
||||
selectedContactIds.has(contact.id)
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "border-border hover:border-muted-foreground"
|
||||
)}>
|
||||
{selectedContactIds.has(contact.id) && <Check className="w-2.5 h-2.5" />}
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<ContactListItem
|
||||
contact={contact}
|
||||
isSelected={contact.id === selectedContactId}
|
||||
onClick={() => onSelectContact(contact.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user