diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index df6f08e7..be13da5e 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -3,16 +3,16 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; -import { ArrowLeft, Users, BookUser } from "lucide-react"; +import { ArrowLeft, Users } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { ContactList } from "@/components/contacts/contact-list"; import { ContactDetail } from "@/components/contacts/contact-detail"; import { ContactForm } from "@/components/contacts/contact-form"; -import { ContactGroupList } from "@/components/contacts/contact-group-list"; import { ContactGroupForm } from "@/components/contacts/contact-group-form"; import { ContactGroupDetail } from "@/components/contacts/contact-group-detail"; +import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar"; import { exportContacts } from "@/components/contacts/contact-export"; import { useContactStore, getContactDisplayName } from "@/stores/contact-store"; import { useAuthStore } from "@/stores/auth-store"; @@ -45,11 +45,9 @@ export default function ContactsPage() { selectedContactId, searchQuery, supportsSync, - activeTab, selectedContactIds, setSelectedContact, setSearchQuery, - setActiveTab, fetchContacts, createContact, updateContact, @@ -64,6 +62,7 @@ export default function ContactsPage() { removeMembersFromGroup, deleteGroup, toggleContactSelection, + selectRangeContacts, selectAllContacts, clearSelection, bulkDeleteContacts, @@ -71,17 +70,25 @@ export default function ContactsPage() { } = useContactStore(); const [view, setView] = useState("list"); + const [activeCategory, setActiveCategory] = useState("all"); const [selectedGroupId, setSelectedGroupId] = useState(null); const hasFetched = useRef(false); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const isMobile = useIsMobile(); - // Sidebar resize state - const [contactsSidebarWidth, setContactsSidebarWidth] = useState(() => { - try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; } + // Panel resize state - sidebar (categories) + const [sidebarWidth, setSidebarWidth] = useState(() => { + try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 180; } catch { return 180; } }); - const [isResizing, setIsResizing] = useState(false); - const dragStartWidth = useRef(256); + const [isSidebarResizing, setIsSidebarResizing] = useState(false); + const sidebarDragStartWidth = useRef(180); + + // Panel resize state - contact list + const [listWidth, setListWidth] = useState(() => { + try { const v = localStorage.getItem("contacts-list-width"); return v ? Number(v) : 320; } catch { return 320; } + }); + const [isListResizing, setIsListResizing] = useState(false); + const listDragStartWidth = useRef(320); // Check auth on mount useEffect(() => { @@ -110,6 +117,30 @@ export default function ContactsPage() { const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null; const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : []; + // Contacts to display based on active category + const displayedContacts = useMemo(() => { + if (activeCategory === "all") return individuals; + // Show members of the selected group + return getGroupMembers(activeCategory.groupId); + }, [activeCategory, individuals, getGroupMembers]); + + // Label for the current category + const categoryLabel = useMemo(() => { + if (activeCategory === "all") return t("tabs.all"); + const group = contacts.find(c => c.id === activeCategory.groupId); + return group ? getContactDisplayName(group) : t("tabs.all"); + }, [activeCategory, contacts, t]); + + const handleSelectCategory = useCallback((category: ContactCategory) => { + setActiveCategory(category); + clearSelection(); + if (typeof category === "object") { + setSelectedGroupId(category.groupId); + } else { + setSelectedGroupId(null); + } + }, [clearSelection]); + const handleSelectContact = (id: string) => { setSelectedContact(id); clearSelection(); @@ -191,6 +222,7 @@ export default function ContactsPage() { const handleSelectGroup = (id: string) => { setSelectedGroupId(id); + setActiveCategory({ groupId: id }); setView("group-detail"); }; @@ -345,7 +377,7 @@ export default function ContactsPage() { isMobile={isMobile} onSelectMember={(id) => { setSelectedContact(id); - setActiveTab("all"); + setActiveCategory("all"); setView("detail"); }} /> @@ -436,6 +468,7 @@ export default function ContactsPage() { return (
+ {/* Navigation Rail - desktop only */} {!isMobile && (
{showListPanel && ( <> + {/* Panel 1: Categories sidebar */} + {!isMobile && ( + <> +
+ +
+ { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }} + onResize={(delta) => setSidebarWidth(Math.max(140, Math.min(300, sidebarDragStartWidth.current + delta)))} + onResizeEnd={() => { + setIsSidebarResizing(false); + localStorage.setItem("contacts-sidebar-width", String(sidebarWidth)); + }} + onDoubleClick={() => { setSidebarWidth(180); localStorage.setItem("contacts-sidebar-width", "180"); }} + /> + + )} + + {/* Panel 2: Contact list */}
-
- - -
- - {activeTab === "all" ? ( - ) : ( - + + {!isMobile && ( + { listDragStartWidth.current = listWidth; setIsListResizing(true); }} + onResize={(delta) => setListWidth(Math.max(220, Math.min(500, listDragStartWidth.current + delta)))} + onResizeEnd={() => { + setIsListResizing(false); + localStorage.setItem("contacts-list-width", String(listWidth)); + }} + onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }} /> )} -
- {!isMobile && ( - { dragStartWidth.current = contactsSidebarWidth; setIsResizing(true); }} - onResize={(delta) => setContactsSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))} - onResizeEnd={() => { - setIsResizing(false); - localStorage.setItem("contacts-sidebar-width", String(contactsSidebarWidth)); - }} - onDoubleClick={() => { setContactsSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }} - /> - )} )} + {/* Panel 3: Detail / Form */} {showRightPanel && (
{isMobile && ( diff --git a/components/contacts/__tests__/contact-list-item.test.tsx b/components/contacts/__tests__/contact-list-item.test.tsx index 8613d5e3..8e1c8574 100644 --- a/components/contacts/__tests__/contact-list-item.test.tsx +++ b/components/contacts/__tests__/contact-list-item.test.tsx @@ -23,33 +23,62 @@ const _emptyContact: ContactCard = { }; describe('ContactListItem', () => { + const baseProps = { + isSelected: false, + isChecked: false, + hasSelection: false, + density: 'regular' as const, + onClick: vi.fn(), + onCheckboxClick: vi.fn(), + }; + it('renders contact name and email', () => { - render(); + render(); expect(screen.getByText('Alice Smith')).toBeInTheDocument(); expect(screen.getByText('alice@example.com')).toBeInTheDocument(); }); - it('renders organization', () => { - render(); + it('renders organization in comfortable density', () => { + render(); expect(screen.getByText('Acme Corp')).toBeInTheDocument(); }); + it('hides organization in regular density', () => { + render(); + expect(screen.queryByText('Acme Corp')).not.toBeInTheDocument(); + }); + it('applies selected styling', () => { - const { container } = render(); - const button = container.querySelector('button'); - expect(button?.className).toContain('bg-accent'); + const { container } = render(); + const div = container.firstElementChild; + expect(div?.className).toContain('bg-blue-200'); }); it('shows email as display name when no name exists', () => { - render(); + render(); const matches = screen.getAllByText('nobody@example.com'); expect(matches.length).toBeGreaterThanOrEqual(1); }); it('calls onClick when clicked', () => { const onClick = vi.fn(); - render(); + render(); fireEvent.click(screen.getByText('Alice Smith')); expect(onClick).toHaveBeenCalledOnce(); }); + + it('does not show checkbox when hasSelection is false', () => { + const { container } = render(); + expect(container.querySelector('button')).not.toBeInTheDocument(); + }); + + it('shows checkbox when hasSelection is true', () => { + const { container } = render(); + expect(container.querySelector('button')).toBeInTheDocument(); + }); + + it('hides avatar in extra-compact density', () => { + const { container } = render(); + expect(container.querySelector('[data-testid="avatar"]') || container.querySelector('.rounded-full')).toBeNull(); + }); }); diff --git a/components/contacts/__tests__/contact-list.test.tsx b/components/contacts/__tests__/contact-list.test.tsx index 9a7754aa..8e95a46a 100644 --- a/components/contacts/__tests__/contact-list.test.tsx +++ b/components/contacts/__tests__/contact-list.test.tsx @@ -36,15 +36,15 @@ const defaultProps = { onSearchChange: vi.fn(), onSelectContact: vi.fn(), onCreateNew: vi.fn(), - supportsSync: true, + categoryLabel: 'All Contacts', selectedContactIds: new Set(), onToggleSelection: vi.fn(), + onSelectRangeContacts: vi.fn(), onSelectAll: vi.fn(), onClearSelection: vi.fn(), onBulkDelete: vi.fn(), onBulkAddToGroup: vi.fn(), onBulkExport: vi.fn(), - groups: [], }; describe('ContactList', () => { @@ -70,32 +70,14 @@ describe('ContactList', () => { expect(screen.getByText('empty_search')).toBeInTheDocument(); }); - it('shows local mode banner when supportsSync is false', () => { - render(); - expect(screen.getByText('local_mode')).toBeInTheDocument(); - }); - - it('hides local mode banner when supportsSync is true', () => { - render(); - expect(screen.queryByText('local_mode')).not.toBeInTheDocument(); - }); - - it('calls onCreateNew when create button is clicked', () => { - const onCreateNew = vi.fn(); - render(); - fireEvent.click(screen.getByText('create_new')); - expect(onCreateNew).toHaveBeenCalledOnce(); - }); - it('shows bulk action bar when contacts are selected', () => { render(); expect(screen.getByText('bulk.delete')).toBeInTheDocument(); expect(screen.getByText('bulk.export')).toBeInTheDocument(); }); - it('excludes groups from the list', () => { - render(); - expect(screen.getByText('Alice Smith')).toBeInTheDocument(); - expect(screen.queryByText('Team')).not.toBeInTheDocument(); + it('shows category label with count', () => { + render(); + expect(screen.getByText('All Contacts (2)')).toBeInTheDocument(); }); }); diff --git a/components/contacts/contact-list-item.tsx b/components/contacts/contact-list-item.tsx index e87ce17a..920c274c 100644 --- a/components/contacts/contact-list-item.tsx +++ b/components/contacts/contact-list-item.tsx @@ -4,14 +4,20 @@ 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"; +import { CheckSquare, Square } from "lucide-react"; +import type { Density } from "@/stores/settings-store"; interface ContactListItemProps { contact: ContactCard; isSelected: boolean; - onClick: () => void; + isChecked: boolean; + hasSelection: boolean; + density: Density; + onClick: (e: React.MouseEvent) => void; + onCheckboxClick: (e: React.MouseEvent) => void; } -export function ContactListItem({ contact, isSelected, onClick }: ContactListItemProps) { +export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, onClick, onCheckboxClick }: ContactListItemProps) { const name = getContactDisplayName(contact); const email = getContactPrimaryEmail(contact); const org = contact.organizations @@ -19,27 +25,51 @@ export function ContactListItem({ contact, isSelected, onClick }: ContactListIte : undefined; return ( - + )} + + {density !== 'extra-compact' && ( + + )} +
{name || email || "—"}
- {email && name && ( + {density !== 'extra-compact' && email && name && (
{email}
)} - {org && ( + {density === 'comfortable' && org && (
{org}
)}
- +
); } diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 2ffce517..e4bd9c90 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -2,13 +2,14 @@ import { useMemo } from "react"; import { useTranslations } from "next-intl"; -import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X, UserPlus } from "lucide-react"; +import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { ContactListItem } from "./contact-list-item"; import { cn } from "@/lib/utils"; import type { ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName } from "@/stores/contact-store"; +import { useSettingsStore } from "@/stores/settings-store"; interface ContactListProps { contacts: ContactCard[]; @@ -17,10 +18,11 @@ interface ContactListProps { onSearchChange: (query: string) => void; onSelectContact: (id: string) => void; onCreateNew: () => void; - supportsSync: boolean; + categoryLabel: string; className?: string; selectedContactIds: Set; onToggleSelection: (id: string) => void; + onSelectRangeContacts: (id: string, sortedIds: string[]) => void; onSelectAll: (ids: string[]) => void; onClearSelection: () => void; onBulkDelete: () => void; @@ -35,10 +37,11 @@ export function ContactList({ onSearchChange, onSelectContact, onCreateNew, - supportsSync, + categoryLabel, className, selectedContactIds, onToggleSelection, + onSelectRangeContacts, onSelectAll, onClearSelection, onBulkDelete, @@ -46,18 +49,27 @@ export function ContactList({ onBulkExport, }: ContactListProps) { const t = useTranslations("contacts"); + const density = useSettingsStore((state) => state.density); const filtered = useMemo(() => { - const individuals = contacts.filter(c => c.kind !== "group"); - if (!searchQuery) return individuals; + if (!searchQuery) return contacts; const lower = searchQuery.toLowerCase(); - return individuals.filter((c) => { + return contacts.filter((c) => { const name = getContactDisplayName(c).toLowerCase(); const emails = c.emails ? Object.values(c.emails).map((e) => e.address.toLowerCase()) : []; + const phones = c.phones + ? Object.values(c.phones).map((p) => p.number?.toLowerCase() || "") + : []; + const org = c.organizations + ? Object.values(c.organizations).map((o) => o.name?.toLowerCase() || "") + : []; return ( - name.includes(lower) || emails.some((e) => e.includes(lower)) + name.includes(lower) || + emails.some((e) => e.includes(lower)) || + phones.some((p) => p.includes(lower)) || + org.some((o) => o.includes(lower)) ); }); }, [contacts, searchQuery]); @@ -70,41 +82,51 @@ export function ContactList({ }); }, [filtered]); + const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]); + const hasSelection = selectedContactIds.size > 0; const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id)); return (
-
+ {/* Search header */} +
-

{t("title")}

- + + {categoryLabel} ({contacts.length}) +
-
- + onSearchChange(e.target.value)} - className="pl-9" + className="pl-8 h-8 text-sm" />
- - {!supportsSync && ( -
- - {t("local_mode")} -
- )}
+ {/* Bulk action bar */} {hasSelection && ( -
- +
+ + {t("bulk.selected", { count: selectedContactIds.size })}
@@ -131,43 +153,19 @@ export function ContactList({
)} - {sorted.length > 0 && ( -
- -
- )} - + {/* Contact list */}
{sorted.length === 0 ? (
{searchQuery ? ( <> - +

{t("empty_search")}

{t("empty_search_hint")}

-
+ )}
) : ( -
+
{sorted.map((contact) => ( -
- -
- onSelectContact(contact.id)} - /> -
-
+ } else if (e.shiftKey) { + e.preventDefault(); + onSelectRangeContacts(contact.id, sortedIds); + } else { + if (hasSelection) onClearSelection(); + onSelectContact(contact.id); + } + }} + onCheckboxClick={(e) => { + e.stopPropagation(); + onToggleSelection(contact.id); + }} + /> ))}
)} diff --git a/components/contacts/contacts-sidebar.tsx b/components/contacts/contacts-sidebar.tsx new file mode 100644 index 00000000..a21a85d8 --- /dev/null +++ b/components/contacts/contacts-sidebar.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { BookUser, Users, Plus, UserPlus } 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"; + +export type ContactCategory = "all" | { groupId: string }; + +interface ContactsSidebarProps { + groups: ContactCard[]; + individuals: ContactCard[]; + activeCategory: ContactCategory; + onSelectCategory: (category: ContactCategory) => void; + onCreateGroup: () => void; + onCreateContact: () => void; + className?: string; +} + +export function ContactsSidebar({ + groups, + individuals, + activeCategory, + onSelectCategory, + onCreateGroup, + onCreateContact, + className, +}: ContactsSidebarProps) { + const t = useTranslations("contacts"); + + const sortedGroups = useMemo(() => { + return [...groups].sort((a, b) => + getContactDisplayName(a).localeCompare(getContactDisplayName(b)) + ); + }, [groups]); + + const isAllActive = activeCategory === "all"; + + return ( +
+ {/* Header */} +
+ {t("title")} + +
+ + {/* Categories */} +
+ {/* All contacts */} + + + {/* Groups section */} + {(sortedGroups.length > 0) && ( +
+
+ + {t("tabs.groups")} + + +
+ + {sortedGroups.map((group) => { + const isActive = typeof activeCategory === "object" && activeCategory.groupId === group.id; + const memberCount = group.members + ? Object.values(group.members).filter(Boolean).length + : 0; + + return ( + + ); + })} +
+ )} + + {sortedGroups.length === 0 && ( +
+
+ + {t("tabs.groups")} + +
+ +
+ )} +
+
+ ); +} diff --git a/stores/contact-store.ts b/stores/contact-store.ts index 1ca8e622..6df85ee2 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -44,6 +44,7 @@ interface ContactStore { supportsSync: boolean; selectedContactIds: Set; + lastSelectedContactId: string | null; activeTab: 'all' | 'groups'; fetchContacts: (client: JMAPClient) => Promise; @@ -74,6 +75,7 @@ interface ContactStore { deleteGroup: (client: JMAPClient | null, groupId: string) => Promise; toggleContactSelection: (id: string) => void; + selectRangeContacts: (targetId: string, sortedIds: string[]) => void; selectAllContacts: (ids: string[]) => void; clearSelection: () => void; bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise; @@ -93,6 +95,7 @@ export const useContactStore = create()( error: null, supportsSync: false, selectedContactIds: new Set(), + lastSelectedContactId: null, activeTab: 'all' as const, fetchContacts: async (client) => { @@ -382,12 +385,28 @@ export const useContactStore = create()( } else { next.add(id); } - return { selectedContactIds: next }; + return { selectedContactIds: next, lastSelectedContactId: id }; }), + selectRangeContacts: (targetId, sortedIds) => { + const { lastSelectedContactId, selectedContactIds } = get(); + const anchorId = lastSelectedContactId || sortedIds[0]; + if (!anchorId) return; + const anchorIndex = sortedIds.indexOf(anchorId); + const targetIndex = sortedIds.indexOf(targetId); + if (anchorIndex === -1 || targetIndex === -1) return; + const start = Math.min(anchorIndex, targetIndex); + const end = Math.max(anchorIndex, targetIndex); + const newSelection = new Set(selectedContactIds); + for (let i = start; i <= end; i++) { + newSelection.add(sortedIds[i]); + } + set({ selectedContactIds: newSelection }); + }, + selectAllContacts: (ids) => set({ selectedContactIds: new Set(ids) }), - clearSelection: () => set({ selectedContactIds: new Set() }), + clearSelection: () => set({ selectedContactIds: new Set(), lastSelectedContactId: null }), bulkDeleteContacts: async (client, ids) => { set({ error: null });