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>
|
||||
)}
|
||||
|
||||
+113
-54
@@ -9,9 +9,13 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { SearchChips } from "@/components/search/search-chips";
|
||||
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
|
||||
|
||||
interface EmailListProps {
|
||||
emails: Email[];
|
||||
@@ -19,9 +23,7 @@ interface EmailListProps {
|
||||
onEmailSelect?: (email: Email) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
// Mobile conversation view handler
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
// Context menu actions
|
||||
onReply?: (email: Email) => void;
|
||||
onReplyAll?: (email: Email) => void;
|
||||
onForward?: (email: Email) => void;
|
||||
@@ -76,20 +78,37 @@ export function EmailList({
|
||||
isLoadingThread,
|
||||
toggleThreadExpansion,
|
||||
fetchThreadEmails,
|
||||
searchFilters,
|
||||
setSearchFilters,
|
||||
clearSearchFilters,
|
||||
advancedSearch,
|
||||
} = useEmailStore();
|
||||
|
||||
// Group emails by thread
|
||||
const threadGroups = useMemo(() => {
|
||||
const groups = groupEmailsByThread(emails);
|
||||
return sortThreadGroups(groups);
|
||||
}, [emails]);
|
||||
|
||||
// Context menu state
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
// Loading skeleton component - gentler, no pulsing
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const listDensity = useSettingsStore((state) => state.listDensity);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
|
||||
const estimateSize = useCallback(() => {
|
||||
const base = { compact: 72, regular: 88, comfortable: 104 }[listDensity];
|
||||
return showPreview ? base + 40 : base;
|
||||
}, [listDensity, showPreview]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: threadGroups.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize,
|
||||
overscan: 5,
|
||||
getItemKey: (index) => threadGroups[index]?.threadId ?? String(index),
|
||||
});
|
||||
|
||||
const LoadingSkeleton = () => (
|
||||
<div className="animate-in fade-in duration-200">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
@@ -119,7 +138,7 @@ export function EmailList({
|
||||
try {
|
||||
await batchMarkAsRead(client, read);
|
||||
} finally {
|
||||
setTimeout(() => setIsProcessing(false), 500); // Small delay for visual feedback
|
||||
setTimeout(() => setIsProcessing(false), 500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,52 +152,56 @@ export function EmailList({
|
||||
}
|
||||
};
|
||||
|
||||
// Intersection observer for infinite scroll
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
|
||||
loadMoreEmails(client);
|
||||
}
|
||||
}, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]);
|
||||
|
||||
// Handle thread expansion and fetch complete thread
|
||||
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
|
||||
const isExpanded = expandedThreadIds.has(threadId);
|
||||
|
||||
if (!isExpanded && client) {
|
||||
// Expanding - fetch complete thread emails
|
||||
toggleThreadExpansion(threadId);
|
||||
await fetchThreadEmails(client, threadId);
|
||||
} else {
|
||||
// Collapsing - just toggle
|
||||
toggleThreadExpansion(threadId);
|
||||
}
|
||||
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
|
||||
|
||||
// Range-based load more: trigger when last visible item is near the end
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
handleLoadMore();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
const currentTarget = observerTarget.current;
|
||||
if (currentTarget) {
|
||||
observer.observe(currentTarget);
|
||||
if (lastVirtualItemIndex === undefined) return;
|
||||
if (lastVirtualItemIndex >= threadGroups.length - 5) {
|
||||
handleLoadMore();
|
||||
}
|
||||
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
|
||||
|
||||
return () => {
|
||||
if (currentTarget) {
|
||||
observer.unobserve(currentTarget);
|
||||
}
|
||||
};
|
||||
}, [handleLoadMore]);
|
||||
// Scroll to the thread group containing the selected email
|
||||
useEffect(() => {
|
||||
if (!selectedEmailId) return;
|
||||
const index = threadGroups.findIndex(thread =>
|
||||
thread.latestEmail.id === selectedEmailId ||
|
||||
thread.emails.some(e => e.id === selectedEmailId)
|
||||
);
|
||||
if (index >= 0) {
|
||||
virtualizer.scrollToIndex(index, { align: 'auto' });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedEmailId]);
|
||||
|
||||
// Re-measure all items when density or preview settings change
|
||||
useEffect(() => {
|
||||
virtualizer.measure();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [listDensity, showPreview]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Batch Actions Toolbar with smooth transition */}
|
||||
{/* Batch Actions Toolbar */}
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out overflow-hidden",
|
||||
@@ -249,6 +272,22 @@ export function EmailList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Search Filter Chips */}
|
||||
{!isFilterEmpty(searchFilters) && (
|
||||
<SearchChips
|
||||
filters={searchFilters}
|
||||
onRemoveFilter={(key) => {
|
||||
const resetValue = DEFAULT_SEARCH_FILTERS[key];
|
||||
setSearchFilters({ [key]: resetValue });
|
||||
if (client) advancedSearch(client);
|
||||
}}
|
||||
onClearAll={() => {
|
||||
clearSearchFilters();
|
||||
if (client) advancedSearch(client);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* List Header */}
|
||||
<div className="px-4 py-3 border-b bg-muted/50 border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -281,8 +320,8 @@ export function EmailList({
|
||||
</div>
|
||||
|
||||
{/* Email List */}
|
||||
<div className="flex-1 overflow-y-auto bg-background relative">
|
||||
{/* Loading overlay - shows on top of existing emails */}
|
||||
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
|
||||
{/* Loading overlay */}
|
||||
{isLoading && emails.length > 0 && (
|
||||
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-background/90 px-4 py-2 rounded-full shadow-sm border border-border">
|
||||
@@ -292,7 +331,6 @@ export function EmailList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show skeleton only on initial load (no emails yet) */}
|
||||
{isLoading && emails.length === 0 ? (
|
||||
<LoadingSkeleton />
|
||||
) : emails.length === 0 && !isLoading ? (
|
||||
@@ -302,24 +340,47 @@ export function EmailList({
|
||||
<p className="text-sm mt-1 text-muted-foreground">{t('no_emails_description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("transition-opacity duration-200", isLoading && "opacity-50")}>
|
||||
{threadGroups.map((thread) => (
|
||||
<ThreadListItem
|
||||
key={thread.threadId}
|
||||
thread={thread}
|
||||
isExpanded={expandedThreadIds.has(thread.threadId)}
|
||||
selectedEmailId={selectedEmailId}
|
||||
isLoading={isLoadingThread === thread.threadId}
|
||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
))}
|
||||
<>
|
||||
<div
|
||||
className={cn("transition-opacity duration-200", isLoading && "opacity-50")}
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const thread = threadGroups[virtualItem.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<ThreadListItem
|
||||
thread={thread}
|
||||
isExpanded={expandedThreadIds.has(thread.threadId)}
|
||||
selectedEmailId={selectedEmailId}
|
||||
isLoading={isLoadingThread === thread.threadId}
|
||||
expandedEmails={threadEmailsCache.get(thread.threadId)}
|
||||
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
|
||||
onEmailSelect={(email) => onEmailSelect?.(email)}
|
||||
onContextMenu={openContextMenu}
|
||||
onOpenConversation={onOpenConversation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Intersection observer target for infinite scroll - always present */}
|
||||
<div ref={observerTarget} className="py-4 flex justify-center">
|
||||
<div className="py-4 flex justify-center">
|
||||
{isLoadingMore && hasMoreEmails && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
@@ -332,7 +393,7 @@ export function EmailList({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -349,7 +410,6 @@ export function EmailList({
|
||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
|
||||
selectedCount={selectedEmailIds.size}
|
||||
// Single email actions
|
||||
onReply={() => onReply?.(contextMenu.data!)}
|
||||
onReplyAll={() => onReplyAll?.(contextMenu.data!)}
|
||||
onForward={() => onForward?.(contextMenu.data!)}
|
||||
@@ -361,7 +421,6 @@ export function EmailList({
|
||||
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)}
|
||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)}
|
||||
onUndoSpam={() => onUndoSpam?.(contextMenu.data!)}
|
||||
// Batch actions
|
||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||
onBatchDelete={() => client && batchDelete(client)}
|
||||
onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)}
|
||||
@@ -399,4 +458,4 @@ export function EmailList({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -16,14 +17,13 @@ interface ThreadListItemProps {
|
||||
isExpanded: boolean;
|
||||
selectedEmailId?: string;
|
||||
isLoading?: boolean;
|
||||
expandedEmails?: Email[]; // Full thread emails when expanded
|
||||
expandedEmails?: Email[];
|
||||
onToggleExpand: () => void;
|
||||
onEmailSelect: (email: Email) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
onOpenConversation?: (thread: ThreadGroup) => void; // Mobile: open full conversation view
|
||||
onOpenConversation?: (thread: ThreadGroup) => void;
|
||||
}
|
||||
|
||||
// Color tag mapping
|
||||
const colorTags = {
|
||||
red: "bg-red-50 dark:bg-red-950/30",
|
||||
orange: "bg-orange-50 dark:bg-orange-950/30",
|
||||
@@ -34,90 +34,41 @@ const colorTags = {
|
||||
pink: "bg-pink-50 dark:bg-pink-950/30",
|
||||
} as const;
|
||||
|
||||
export function ThreadListItem({
|
||||
thread,
|
||||
isExpanded,
|
||||
selectedEmailId,
|
||||
isLoading = false,
|
||||
expandedEmails,
|
||||
onToggleExpand,
|
||||
onEmailSelect,
|
||||
onContextMenu,
|
||||
onOpenConversation,
|
||||
}: ThreadListItemProps) {
|
||||
const t = useTranslations('threads');
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
|
||||
interface SingleEmailItemProps {
|
||||
email: Email;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
}
|
||||
|
||||
// Get color tag from thread
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
|
||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag }, ref) {
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const sender = email.from?.[0];
|
||||
|
||||
// Check if latest email is selected
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
thread.emails.some(e => e.id === selectedEmailId);
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
// Single email thread - render as regular email, no expand
|
||||
if (emailCount === 1) {
|
||||
return (
|
||||
<SingleEmailItem
|
||||
email={latestEmail}
|
||||
selected={selectedEmailId === latestEmail.id}
|
||||
onClick={() => onEmailSelect(latestEmail)}
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
colorTag={colorTag}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Get emails to display when expanded
|
||||
const emailsToShow = expandedEmails || thread.emails;
|
||||
|
||||
const handleHeaderClick = (e: React.MouseEvent) => {
|
||||
// Mobile: open conversation view instead of inline expansion
|
||||
if (isMobile && onOpenConversation) {
|
||||
onOpenConversation(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop: If clicking directly on the expand icon area, toggle expansion
|
||||
// Otherwise, select the latest email
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-expand-toggle]')) {
|
||||
onToggleExpand();
|
||||
} else {
|
||||
// Clicking on the row selects the latest email but also expands
|
||||
if (!isExpanded) {
|
||||
onToggleExpand();
|
||||
}
|
||||
onEmailSelect(latestEmail);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, latestEmail);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
{/* Thread Header (collapsed view) */}
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative group cursor-pointer transition-all duration-200",
|
||||
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
||||
colorTag ? colorTag : (
|
||||
isSelected
|
||||
selected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
isSelected && !colorTag && "shadow-sm",
|
||||
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
|
||||
selected && !colorTag && "shadow-sm",
|
||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
||||
isExpanded && "border-b border-border/50"
|
||||
isUnread && !colorTag && "bg-accent/30"
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
>
|
||||
@@ -125,257 +76,281 @@ export function ThreadListItem({
|
||||
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
|
||||
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
|
||||
}}>
|
||||
{/* Expand/Collapse Button - Hidden on mobile */}
|
||||
{!isMobile && (
|
||||
<button
|
||||
data-expand-toggle
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
||||
"hover:bg-muted/50 hover:scale-110",
|
||||
"active:scale-95",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="w-6 flex-shrink-0" />
|
||||
|
||||
{/* Unread indicator */}
|
||||
{hasUnread && (
|
||||
{isUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
<Avatar
|
||||
name={latestEmail.from?.[0]?.name}
|
||||
email={latestEmail.from?.[0]?.email}
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* First Line: Participants and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
hasUnread
|
||||
isUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{participantNames.join(", ")}
|
||||
</span>
|
||||
{/* Email count badge */}
|
||||
<span className={cn(
|
||||
"flex-shrink-0 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
||||
hasUnread
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{emailCount}
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasStarred && (
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{hasAttachment && (
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0 tabular-nums",
|
||||
hasUnread
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
hasUnread
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
{email.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview */}
|
||||
{showPreview && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
hasUnread
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{latestEmail.preview || "No preview available"}
|
||||
{email.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
{/* Expanded Thread Emails - Desktop only */}
|
||||
{isExpanded && !isMobile && (
|
||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||
{isLoading ? (
|
||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : (
|
||||
emailsToShow.map((email, index) => (
|
||||
<ThreadEmailItem
|
||||
key={email.id}
|
||||
email={email}
|
||||
selected={email.id === selectedEmailId}
|
||||
isLast={index === emailsToShow.length - 1}
|
||||
onClick={() => onEmailSelect(email)}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))
|
||||
export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemProps>(
|
||||
function ThreadListItem({
|
||||
thread,
|
||||
isExpanded,
|
||||
selectedEmailId,
|
||||
isLoading = false,
|
||||
expandedEmails,
|
||||
onToggleExpand,
|
||||
onEmailSelect,
|
||||
onContextMenu,
|
||||
onOpenConversation,
|
||||
}, ref) {
|
||||
const t = useTranslations('threads');
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
|
||||
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
|
||||
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
thread.emails.some(e => e.id === selectedEmailId);
|
||||
|
||||
if (emailCount === 1) {
|
||||
return (
|
||||
<SingleEmailItem
|
||||
ref={ref}
|
||||
email={latestEmail}
|
||||
selected={selectedEmailId === latestEmail.id}
|
||||
onClick={() => onEmailSelect(latestEmail)}
|
||||
onContextMenu={onContextMenu}
|
||||
showPreview={showPreview}
|
||||
colorTag={colorTag}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const emailsToShow = expandedEmails || thread.emails;
|
||||
|
||||
const handleHeaderClick = (e: React.MouseEvent) => {
|
||||
if (isMobile && onOpenConversation) {
|
||||
onOpenConversation(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-expand-toggle]')) {
|
||||
onToggleExpand();
|
||||
} else {
|
||||
if (!isExpanded) {
|
||||
onToggleExpand();
|
||||
}
|
||||
onEmailSelect(latestEmail);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, latestEmail);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="border-b border-border">
|
||||
<div
|
||||
className={cn(
|
||||
"relative group cursor-pointer transition-all duration-200",
|
||||
colorTag ? colorTag : (
|
||||
isSelected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
isSelected && !colorTag && "shadow-sm",
|
||||
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
||||
isExpanded && "border-b border-border/50"
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3 px-4" style={{
|
||||
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
|
||||
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
|
||||
}}>
|
||||
{!isMobile && (
|
||||
<button
|
||||
data-expand-toggle
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
|
||||
"hover:bg-muted/50 hover:scale-110",
|
||||
"active:scale-95",
|
||||
"text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Avatar
|
||||
name={latestEmail.from?.[0]?.name}
|
||||
email={latestEmail.from?.[0]?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
hasUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{participantNames.join(", ")}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"flex-shrink-0 px-1.5 py-0.5 text-xs rounded-full font-medium",
|
||||
hasUnread
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{emailCount}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0 tabular-nums",
|
||||
hasUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(latestEmail.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
hasUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
hasUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{latestEmail.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Single email item (for threads with only 1 email)
|
||||
function SingleEmailItem({
|
||||
email,
|
||||
selected,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
showPreview,
|
||||
colorTag,
|
||||
}: {
|
||||
email: Email;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
|
||||
showPreview: boolean;
|
||||
colorTag: string | null;
|
||||
}) {
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const sender = email.from?.[0];
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
||||
colorTag ? colorTag : (
|
||||
selected
|
||||
? "bg-accent"
|
||||
: "bg-background"
|
||||
),
|
||||
selected && !colorTag && "shadow-sm",
|
||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
isUnread && !colorTag && "bg-accent/30"
|
||||
)}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3 px-4" style={{
|
||||
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
|
||||
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
|
||||
}}>
|
||||
{/* Spacer for alignment with thread items */}
|
||||
<div className="w-6 flex-shrink-0" />
|
||||
|
||||
{/* Unread indicator */}
|
||||
{isUnread && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2">
|
||||
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
|
||||
{isExpanded && !isMobile && (
|
||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||
{isLoading ? (
|
||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
{t('loading')}
|
||||
</div>
|
||||
) : (
|
||||
emailsToShow.map((email, index) => (
|
||||
<ThreadEmailItem
|
||||
key={email.id}
|
||||
email={email}
|
||||
selected={email.id === selectedEmailId}
|
||||
isLast={index === emailsToShow.length - 1}
|
||||
onClick={() => onEmailSelect(email)}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar */}
|
||||
<Avatar
|
||||
name={sender?.name}
|
||||
email={sender?.email}
|
||||
size="md"
|
||||
className="flex-shrink-0 shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* First Line: Sender and Date */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className={cn(
|
||||
"truncate text-sm",
|
||||
isUnread
|
||||
? "font-bold text-foreground"
|
||||
: "font-medium text-muted-foreground"
|
||||
)}>
|
||||
{sender?.name || sender?.email || "Unknown"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStarred && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
{email.hasAttachment && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0 tabular-nums",
|
||||
isUnread
|
||||
? "text-foreground font-semibold"
|
||||
: "text-muted-foreground"
|
||||
)}>
|
||||
{formatDate(email.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second Line: Subject */}
|
||||
<div className={cn(
|
||||
"mb-1 line-clamp-1 text-sm",
|
||||
isUnread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
</div>
|
||||
|
||||
{/* Third Line: Preview */}
|
||||
{showPreview && (
|
||||
<p className={cn(
|
||||
"text-sm leading-relaxed line-clamp-2",
|
||||
isUnread
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{email.preview || "No preview available"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -25,12 +25,17 @@ import {
|
||||
Users,
|
||||
User,
|
||||
BookUser,
|
||||
Palmtree,
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { activeFilterCount } from "@/lib/jmap/search-utils";
|
||||
import { useVacationStore } from "@/stores/vacation-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -222,6 +227,57 @@ function MailboxTreeItem({
|
||||
);
|
||||
}
|
||||
|
||||
function VacationIndicator() {
|
||||
const t = useTranslations('sidebar');
|
||||
const { isEnabled, isSupported } = useVacationStore();
|
||||
|
||||
if (!isSupported || !isEnabled) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="relative group"
|
||||
title={t("vacation_active")}
|
||||
>
|
||||
<Palmtree className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400" />
|
||||
<span className={cn(
|
||||
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1",
|
||||
"bg-popover text-popover-foreground text-xs rounded shadow-lg",
|
||||
"whitespace-nowrap opacity-0 group-hover:opacity-100",
|
||||
"pointer-events-none transition-opacity duration-200 z-50"
|
||||
)}>
|
||||
{t("vacation_active")}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedSearchToggle() {
|
||||
const tSearch = useTranslations("advanced_search");
|
||||
const { searchFilters, isAdvancedSearchOpen, toggleAdvancedSearch } = useEmailStore();
|
||||
const filterCount = activeFilterCount(searchFilters);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAdvancedSearch}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
isAdvancedSearchOpen || filterCount > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={tSearch("toggle_filters")}
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
{filterCount > 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">
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
mailboxes = [],
|
||||
selectedMailbox = "",
|
||||
@@ -369,33 +425,36 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
{/* Search + Advanced Filter Toggle */}
|
||||
{!isCollapsed && (
|
||||
<div className="px-4 py-3">
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn("pl-9", searchQuery && "pr-8")}
|
||||
data-search-input
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
onClearSearch?.();
|
||||
}}
|
||||
className="absolute right-2 top-1/2 transform -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>
|
||||
)}
|
||||
</form>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<form onSubmit={handleSearch} className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn("pl-9", searchQuery && "pr-8")}
|
||||
data-search-input
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
onClearSearch?.();
|
||||
}}
|
||||
className="absolute right-2 top-1/2 transform -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>
|
||||
)}
|
||||
</form>
|
||||
<AdvancedSearchToggle />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -505,6 +564,7 @@ export function Sidebar({
|
||||
<span className="flex items-center gap-2">
|
||||
<Menu className="w-4 h-4" />
|
||||
Menu
|
||||
<VacationIndicator />
|
||||
{/* Push Connection Status Indicator */}
|
||||
<span
|
||||
className="relative group"
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Paperclip,
|
||||
Star,
|
||||
Mail,
|
||||
MailOpen,
|
||||
X,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SearchFilters } from "@/lib/jmap/search-utils";
|
||||
|
||||
interface AdvancedSearchPanelProps {
|
||||
filters: SearchFilters;
|
||||
isOpen: boolean;
|
||||
onFiltersChange: (filters: Partial<SearchFilters>) => void;
|
||||
onClear: () => void;
|
||||
onSearch: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AdvancedSearchPanel({
|
||||
filters,
|
||||
isOpen,
|
||||
onFiltersChange,
|
||||
onClear,
|
||||
onSearch,
|
||||
onClose,
|
||||
}: AdvancedSearchPanelProps) {
|
||||
const t = useTranslations("advanced_search");
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const debouncedSearch = useCallback(() => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onSearch();
|
||||
}, 300);
|
||||
}, [onSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTextChange = (field: keyof SearchFilters, value: string) => {
|
||||
onFiltersChange({ [field]: value });
|
||||
debouncedSearch();
|
||||
};
|
||||
|
||||
const handleToggle = (field: "hasAttachment" | "isUnread" | "isStarred", current: boolean | null) => {
|
||||
const next = current === null ? true : current === true ? false : null;
|
||||
onFiltersChange({ [field]: next });
|
||||
onSearch();
|
||||
};
|
||||
|
||||
const handleDateChange = (field: "dateAfter" | "dateBefore", value: string) => {
|
||||
onFiltersChange({ [field]: value });
|
||||
onSearch();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
onClear();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<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("title")}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={handleClear} className="h-7 px-2 text-xs">
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
{t("clear")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7">
|
||||
<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("from")}</label>
|
||||
<Input
|
||||
value={filters.from}
|
||||
onChange={(e) => handleTextChange("from", e.target.value)}
|
||||
placeholder={t("from_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("to")}</label>
|
||||
<Input
|
||||
value={filters.to}
|
||||
onChange={(e) => handleTextChange("to", e.target.value)}
|
||||
placeholder={t("to_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("subject")}</label>
|
||||
<Input
|
||||
value={filters.subject}
|
||||
onChange={(e) => handleTextChange("subject", e.target.value)}
|
||||
placeholder={t("subject_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("date_after")}</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.dateAfter}
|
||||
onChange={(e) => handleDateChange("dateAfter", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("date_before")}</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.dateBefore}
|
||||
onChange={(e) => handleDateChange("dateBefore", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ToggleFilterButton
|
||||
icon={<Paperclip className="w-3.5 h-3.5" />}
|
||||
label={t("has_attachment")}
|
||||
value={filters.hasAttachment}
|
||||
onClick={() => handleToggle("hasAttachment", filters.hasAttachment)}
|
||||
/>
|
||||
<ToggleFilterButton
|
||||
icon={<Star className="w-3.5 h-3.5" />}
|
||||
label={t("starred")}
|
||||
value={filters.isStarred}
|
||||
onClick={() => handleToggle("isStarred", filters.isStarred)}
|
||||
/>
|
||||
<ToggleFilterButton
|
||||
icon={filters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
|
||||
label={filters.isUnread === false ? t("read") : t("unread")}
|
||||
value={filters.isUnread}
|
||||
onClick={() => handleToggle("isUnread", filters.isUnread)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleFilterButton({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: boolean | null;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SearchFilters } from "@/lib/jmap/search-utils";
|
||||
|
||||
interface SearchChipsProps {
|
||||
filters: SearchFilters;
|
||||
onRemoveFilter: (key: keyof SearchFilters) => void;
|
||||
onClearAll: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SearchChips({
|
||||
filters,
|
||||
onRemoveFilter,
|
||||
onClearAll,
|
||||
className,
|
||||
}: SearchChipsProps) {
|
||||
const t = useTranslations("advanced_search");
|
||||
|
||||
const chips: { key: keyof SearchFilters; label: string; value: string }[] = [];
|
||||
|
||||
if (filters.from) {
|
||||
chips.push({ key: "from", label: t("from"), value: filters.from });
|
||||
}
|
||||
if (filters.to) {
|
||||
chips.push({ key: "to", label: t("to"), value: filters.to });
|
||||
}
|
||||
if (filters.subject) {
|
||||
chips.push({ key: "subject", label: t("subject"), value: filters.subject });
|
||||
}
|
||||
if (filters.body) {
|
||||
chips.push({ key: "body", label: t("body"), value: filters.body });
|
||||
}
|
||||
if (filters.hasAttachment !== null) {
|
||||
chips.push({
|
||||
key: "hasAttachment",
|
||||
label: t("has_attachment"),
|
||||
value: filters.hasAttachment ? t("yes") : t("no"),
|
||||
});
|
||||
}
|
||||
if (filters.dateAfter) {
|
||||
chips.push({ key: "dateAfter", label: t("date_after"), value: filters.dateAfter });
|
||||
}
|
||||
if (filters.dateBefore) {
|
||||
chips.push({ key: "dateBefore", label: t("date_before"), value: filters.dateBefore });
|
||||
}
|
||||
if (filters.isUnread !== null) {
|
||||
chips.push({
|
||||
key: "isUnread",
|
||||
label: filters.isUnread ? t("unread") : t("read"),
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
if (filters.isStarred !== null) {
|
||||
chips.push({
|
||||
key: "isStarred",
|
||||
label: t("starred"),
|
||||
value: filters.isStarred ? t("yes") : t("no"),
|
||||
});
|
||||
}
|
||||
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-2 border-b border-border bg-muted/20 flex items-center gap-2 flex-wrap", className)}>
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.key}
|
||||
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"
|
||||
>
|
||||
<span className="font-medium">{chip.label}</span>
|
||||
{chip.value && (
|
||||
<>
|
||||
<span className="text-primary/60">:</span>
|
||||
<span className="max-w-24 truncate">{chip.value}</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveFilter(chip.key)}
|
||||
className="ml-0.5 p-0.5 rounded-full hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{chips.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("clear_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
function utcToLocalDatetime(utcIso: string): string {
|
||||
const d = new Date(utcIso);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function VacationSettings() {
|
||||
const t = useTranslations('settings.vacation');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const { client } = useAuthStore();
|
||||
const {
|
||||
isEnabled,
|
||||
fromDate,
|
||||
toDate,
|
||||
subject,
|
||||
textBody,
|
||||
isLoading,
|
||||
isSaving,
|
||||
error,
|
||||
isSupported,
|
||||
fetchVacationResponse,
|
||||
updateVacationResponse,
|
||||
} = useVacationStore();
|
||||
|
||||
const [localEnabled, setLocalEnabled] = useState(isEnabled);
|
||||
const [localFromDate, setLocalFromDate] = useState(fromDate || '');
|
||||
const [localToDate, setLocalToDate] = useState(toDate || '');
|
||||
const [localSubject, setLocalSubject] = useState(subject);
|
||||
const [localTextBody, setLocalTextBody] = useState(textBody);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client && isSupported) {
|
||||
void fetchVacationResponse(client);
|
||||
}
|
||||
}, [client, isSupported, fetchVacationResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalEnabled(isEnabled);
|
||||
setLocalFromDate(fromDate || '');
|
||||
setLocalToDate(toDate || '');
|
||||
setLocalSubject(subject);
|
||||
setLocalTextBody(textBody);
|
||||
}, [isEnabled, fromDate, toDate, subject, textBody]);
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate)) {
|
||||
warnings.push(t('warnings.end_before_start'));
|
||||
}
|
||||
|
||||
if (localFromDate && new Date(localFromDate) < new Date()) {
|
||||
warnings.push(t('warnings.start_in_past'));
|
||||
}
|
||||
|
||||
if (localEnabled && !localTextBody.trim()) {
|
||||
warnings.push(t('warnings.empty_body'));
|
||||
}
|
||||
|
||||
setValidationWarnings(warnings);
|
||||
return warnings;
|
||||
}, [localFromDate, localToDate, localEnabled, localTextBody, t]);
|
||||
|
||||
useEffect(() => {
|
||||
validate();
|
||||
}, [validate]);
|
||||
|
||||
const hasChanges =
|
||||
localEnabled !== isEnabled ||
|
||||
(localFromDate || null) !== (fromDate || null) ||
|
||||
(localToDate || null) !== (toDate || null) ||
|
||||
localSubject !== subject ||
|
||||
localTextBody !== textBody;
|
||||
|
||||
const hasBlockingError = !!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!client) return;
|
||||
validate();
|
||||
if (hasBlockingError) return;
|
||||
|
||||
try {
|
||||
await updateVacationResponse(client, {
|
||||
isEnabled: localEnabled,
|
||||
fromDate: localFromDate || null,
|
||||
toDate: localToDate || null,
|
||||
subject: localSubject,
|
||||
textBody: localTextBody,
|
||||
});
|
||||
toast.success(tNotifications('vacation_saved'));
|
||||
} catch (error) {
|
||||
console.error('Failed to save vacation response:', error);
|
||||
toast.error(tNotifications('vacation_save_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<div className="text-sm text-muted-foreground py-4">
|
||||
{t('not_supported')}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('loading')}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<div className="text-sm text-red-600 dark:text-red-400 py-4">
|
||||
{t('fetch_error')}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem
|
||||
label={t('status.label')}
|
||||
description={t('status.description')}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
localEnabled
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{localEnabled ? t('status.active') : t('status.inactive')}
|
||||
</span>
|
||||
<ToggleSwitch checked={localEnabled} onChange={setLocalEnabled} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t('date_range.title')} description={t('date_range.description')}>
|
||||
<SettingItem
|
||||
label={t('date_range.start')}
|
||||
description={t('date_range.start_description')}
|
||||
>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={localFromDate ? utcToLocalDatetime(localFromDate) : ''}
|
||||
onChange={(e) => setLocalFromDate(e.target.value ? new Date(e.target.value).toISOString() : '')}
|
||||
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label={t('date_range.end')}
|
||||
description={t('date_range.end_description')}
|
||||
>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={localToDate ? utcToLocalDatetime(localToDate) : ''}
|
||||
onChange={(e) => setLocalToDate(e.target.value ? new Date(e.target.value).toISOString() : '')}
|
||||
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t('message.title')} description={t('message.description')}>
|
||||
<SettingItem
|
||||
label={t('message.subject_label')}
|
||||
description={t('message.subject_description')}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={localSubject}
|
||||
onChange={(e) => setLocalSubject(e.target.value)}
|
||||
placeholder={t('message.subject_placeholder')}
|
||||
className="w-64 px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</SettingItem>
|
||||
<div className="py-3">
|
||||
<label htmlFor="vacation-body" className="text-sm font-medium text-foreground block mb-1">
|
||||
{t('message.body_label')}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{t('message.body_description')}
|
||||
</p>
|
||||
<textarea
|
||||
id="vacation-body"
|
||||
value={localTextBody}
|
||||
onChange={(e) => setLocalTextBody(e.target.value)}
|
||||
placeholder={t('message.body_placeholder')}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 text-sm rounded bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-y"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{localTextBody.trim() && (
|
||||
<SettingsSection title={t('preview.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
{showPreview ? t('preview.hide') : t('preview.show')}
|
||||
</button>
|
||||
{showPreview && (
|
||||
<div className="mt-3 p-4 rounded border border-border bg-background">
|
||||
{localSubject && (
|
||||
<p className="font-medium text-foreground mb-2">{localSubject}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{validationWarnings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{validationWarnings.map((warning, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasChanges || hasBlockingError}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
t('save')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Avatar } from '../avatar';
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('renders initials from full name', () => {
|
||||
const { container } = render(<Avatar name="Alice Smith" />);
|
||||
expect(container.textContent).toBe('AS');
|
||||
});
|
||||
|
||||
it('renders two letters from single-word name', () => {
|
||||
const { container } = render(<Avatar name="Alice" />);
|
||||
expect(container.textContent).toBe('AL');
|
||||
});
|
||||
|
||||
it('renders single letter from email when no name', () => {
|
||||
const { container } = render(<Avatar email="bob@example.com" />);
|
||||
expect(container.textContent).toBe('B');
|
||||
});
|
||||
|
||||
it('renders "?" when no name or email', () => {
|
||||
const { container } = render(<Avatar />);
|
||||
expect(container.textContent).toBe('?');
|
||||
});
|
||||
|
||||
it('produces consistent background color for same input', () => {
|
||||
const { container: a } = render(<Avatar name="Alice" />);
|
||||
const { container: b } = render(<Avatar name="Alice" />);
|
||||
const colorA = (a.firstChild as HTMLElement).style.backgroundColor;
|
||||
const colorB = (b.firstChild as HTMLElement).style.backgroundColor;
|
||||
expect(colorA).toBe(colorB);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Button } from '../button';
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders with children text', () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles click events', () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>Click</Button>);
|
||||
fireEvent.click(screen.getByText('Click'));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('applies disabled state', () => {
|
||||
render(<Button disabled>Disabled</Button>);
|
||||
expect(screen.getByText('Disabled')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders different variants without errors', () => {
|
||||
const { rerender } = render(<Button variant="default">Default</Button>);
|
||||
expect(screen.getByText('Default')).toBeInTheDocument();
|
||||
|
||||
rerender(<Button variant="ghost">Ghost</Button>);
|
||||
expect(screen.getByText('Ghost')).toBeInTheDocument();
|
||||
|
||||
rerender(<Button variant="outline">Outline</Button>);
|
||||
expect(screen.getByText('Outline')).toBeInTheDocument();
|
||||
|
||||
rerender(<Button variant="destructive">Destructive</Button>);
|
||||
expect(screen.getByText('Destructive')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Input } from '../input';
|
||||
|
||||
describe('Input', () => {
|
||||
it('renders with placeholder', () => {
|
||||
render(<Input placeholder="Enter text" />);
|
||||
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles value changes', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<Input onChange={onChange} />);
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello' } });
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('applies disabled state', () => {
|
||||
render(<Input disabled placeholder="Disabled" />);
|
||||
expect(screen.getByPlaceholderText('Disabled')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('passes type prop', () => {
|
||||
render(<Input type="password" placeholder="Password" />);
|
||||
const input = screen.getByPlaceholderText('Password');
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user