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:
Matthieu MALVACHE
2026-02-16 18:51:30 +01:00
committed by Matthieu MALVACHE
parent 8a5bc9b88d
commit fb414bf2c9
70 changed files with 8160 additions and 547 deletions
@@ -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();
});
});
+32
View File
@@ -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>
);
}
+199
View File
@@ -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>
);
}
+100 -9
View File
@@ -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
View File
@@ -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>
);
}
}
+244 -269
View File
@@ -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>
);
}
);
}
);
+85 -25
View File
@@ -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"
+199
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+266
View File
@@ -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>
);
}
+33
View File
@@ -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);
});
});
+36
View File
@@ -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();
});
});
+28
View File
@@ -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');
});
});