feat: enhance contacts management with sidebar and selection features
This commit is contained in:
@@ -3,16 +3,16 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
import { useRouter } from "@/i18n/navigation";
|
import { useRouter } from "@/i18n/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||||
import { ContactList } from "@/components/contacts/contact-list";
|
import { ContactList } from "@/components/contacts/contact-list";
|
||||||
import { ContactDetail } from "@/components/contacts/contact-detail";
|
import { ContactDetail } from "@/components/contacts/contact-detail";
|
||||||
import { ContactForm } from "@/components/contacts/contact-form";
|
import { ContactForm } from "@/components/contacts/contact-form";
|
||||||
import { ContactGroupList } from "@/components/contacts/contact-group-list";
|
|
||||||
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
||||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
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 { exportContacts } from "@/components/contacts/contact-export";
|
||||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -45,11 +45,9 @@ export default function ContactsPage() {
|
|||||||
selectedContactId,
|
selectedContactId,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
supportsSync,
|
supportsSync,
|
||||||
activeTab,
|
|
||||||
selectedContactIds,
|
selectedContactIds,
|
||||||
setSelectedContact,
|
setSelectedContact,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
setActiveTab,
|
|
||||||
fetchContacts,
|
fetchContacts,
|
||||||
createContact,
|
createContact,
|
||||||
updateContact,
|
updateContact,
|
||||||
@@ -64,6 +62,7 @@ export default function ContactsPage() {
|
|||||||
removeMembersFromGroup,
|
removeMembersFromGroup,
|
||||||
deleteGroup,
|
deleteGroup,
|
||||||
toggleContactSelection,
|
toggleContactSelection,
|
||||||
|
selectRangeContacts,
|
||||||
selectAllContacts,
|
selectAllContacts,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
bulkDeleteContacts,
|
bulkDeleteContacts,
|
||||||
@@ -71,17 +70,25 @@ export default function ContactsPage() {
|
|||||||
} = useContactStore();
|
} = useContactStore();
|
||||||
|
|
||||||
const [view, setView] = useState<View>("list");
|
const [view, setView] = useState<View>("list");
|
||||||
|
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Sidebar resize state
|
// Panel resize state - sidebar (categories)
|
||||||
const [contactsSidebarWidth, setContactsSidebarWidth] = useState(() => {
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; }
|
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 180; } catch { return 180; }
|
||||||
});
|
});
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
|
||||||
const dragStartWidth = useRef(256);
|
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
|
// Check auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -110,6 +117,30 @@ export default function ContactsPage() {
|
|||||||
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
|
||||||
const selectedGroupMembers = selectedGroupId ? getGroupMembers(selectedGroupId) : [];
|
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) => {
|
const handleSelectContact = (id: string) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -191,6 +222,7 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
const handleSelectGroup = (id: string) => {
|
const handleSelectGroup = (id: string) => {
|
||||||
setSelectedGroupId(id);
|
setSelectedGroupId(id);
|
||||||
|
setActiveCategory({ groupId: id });
|
||||||
setView("group-detail");
|
setView("group-detail");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -345,7 +377,7 @@ export default function ContactsPage() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onSelectMember={(id) => {
|
onSelectMember={(id) => {
|
||||||
setSelectedContact(id);
|
setSelectedContact(id);
|
||||||
setActiveTab("all");
|
setActiveCategory("all");
|
||||||
setView("detail");
|
setView("detail");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -436,6 +468,7 @@ export default function ContactsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh bg-background overflow-hidden">
|
<div className="flex h-dvh bg-background overflow-hidden">
|
||||||
|
{/* Navigation Rail - desktop only */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
<div className="w-14 bg-secondary flex flex-col flex-shrink-0" style={{ borderRight: '1px solid rgba(128, 128, 128, 0.3)' }}>
|
||||||
<NavigationRail
|
<NavigationRail
|
||||||
@@ -451,89 +484,81 @@ export default function ContactsPage() {
|
|||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
{showListPanel && (
|
{showListPanel && (
|
||||||
<>
|
<>
|
||||||
|
{/* Panel 1: Categories sidebar */}
|
||||||
|
{!isMobile && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-r border-border flex flex-col flex-shrink-0",
|
||||||
|
!isSidebarResizing && "transition-[width] duration-300"
|
||||||
|
)}
|
||||||
|
style={{ width: `${sidebarWidth}px` }}
|
||||||
|
>
|
||||||
|
<ContactsSidebar
|
||||||
|
groups={groups}
|
||||||
|
individuals={individuals}
|
||||||
|
activeCategory={activeCategory}
|
||||||
|
onSelectCategory={handleSelectCategory}
|
||||||
|
onCreateGroup={handleCreateGroup}
|
||||||
|
onCreateContact={handleCreateNew}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ResizeHandle
|
||||||
|
onResizeStart={() => { 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 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-r border-border bg-secondary flex flex-col flex-shrink-0",
|
"border-r border-border bg-background flex flex-col flex-shrink-0",
|
||||||
isMobile ? "w-full" : "",
|
isMobile ? "w-full" : "",
|
||||||
!isResizing && !isMobile && "transition-[width] duration-300"
|
!isListResizing && !isMobile && "transition-[width] duration-300"
|
||||||
)}
|
)}
|
||||||
style={!isMobile ? { width: `${contactsSidebarWidth}px` } : undefined}
|
style={!isMobile ? { width: `${listWidth}px` } : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex border-b border-border">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("all")}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors touch-manipulation",
|
|
||||||
activeTab === "all"
|
|
||||||
? "border-b-2 border-primary text-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BookUser className="w-4 h-4" />
|
|
||||||
{t("tabs.all")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("groups")}
|
|
||||||
className={cn(
|
|
||||||
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors touch-manipulation",
|
|
||||||
activeTab === "groups"
|
|
||||||
? "border-b-2 border-primary text-primary"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Users className="w-4 h-4" />
|
|
||||||
{t("tabs.groups")}
|
|
||||||
{groups.length > 0 && (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-muted">
|
|
||||||
{groups.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === "all" ? (
|
|
||||||
<ContactList
|
<ContactList
|
||||||
contacts={contacts}
|
contacts={displayedContacts}
|
||||||
selectedContactId={selectedContactId}
|
selectedContactId={selectedContactId}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
onSelectContact={handleSelectContact}
|
onSelectContact={handleSelectContact}
|
||||||
onCreateNew={handleCreateNew}
|
onCreateNew={handleCreateNew}
|
||||||
supportsSync={supportsSync}
|
categoryLabel={categoryLabel}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
selectedContactIds={selectedContactIds}
|
selectedContactIds={selectedContactIds}
|
||||||
onToggleSelection={toggleContactSelection}
|
onToggleSelection={toggleContactSelection}
|
||||||
|
onSelectRangeContacts={selectRangeContacts}
|
||||||
onSelectAll={selectAllContacts}
|
onSelectAll={selectAllContacts}
|
||||||
onClearSelection={clearSelection}
|
onClearSelection={clearSelection}
|
||||||
onBulkDelete={handleBulkDelete}
|
onBulkDelete={handleBulkDelete}
|
||||||
onBulkAddToGroup={handleBulkAddToGroup}
|
onBulkAddToGroup={handleBulkAddToGroup}
|
||||||
onBulkExport={handleBulkExport}
|
onBulkExport={handleBulkExport}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<ContactGroupList
|
|
||||||
groups={groups}
|
{!isMobile && (
|
||||||
selectedGroupId={selectedGroupId}
|
<ResizeHandle
|
||||||
onSelectGroup={handleSelectGroup}
|
onResizeStart={() => { listDragStartWidth.current = listWidth; setIsListResizing(true); }}
|
||||||
onCreateGroup={handleCreateGroup}
|
onResize={(delta) => setListWidth(Math.max(220, Math.min(500, listDragStartWidth.current + delta)))}
|
||||||
searchQuery={searchQuery}
|
onResizeEnd={() => {
|
||||||
className="flex-1"
|
setIsListResizing(false);
|
||||||
|
localStorage.setItem("contacts-list-width", String(listWidth));
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => { setListWidth(320); localStorage.setItem("contacts-list-width", "320"); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
{!isMobile && (
|
|
||||||
<ResizeHandle
|
|
||||||
onResizeStart={() => { 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 && (
|
{showRightPanel && (
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
|
|||||||
@@ -23,33 +23,62 @@ const _emptyContact: ContactCard = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('ContactListItem', () => {
|
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', () => {
|
it('renders contact name and email', () => {
|
||||||
render(<ContactListItem contact={contact} isSelected={false} onClick={vi.fn()} />);
|
render(<ContactListItem contact={contact} {...baseProps} />);
|
||||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
||||||
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
|
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders organization', () => {
|
it('renders organization in comfortable density', () => {
|
||||||
render(<ContactListItem contact={contact} isSelected={false} onClick={vi.fn()} />);
|
render(<ContactListItem contact={contact} {...baseProps} density="comfortable" />);
|
||||||
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides organization in regular density', () => {
|
||||||
|
render(<ContactListItem contact={contact} {...baseProps} density="regular" />);
|
||||||
|
expect(screen.queryByText('Acme Corp')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('applies selected styling', () => {
|
it('applies selected styling', () => {
|
||||||
const { container } = render(<ContactListItem contact={contact} isSelected={true} onClick={vi.fn()} />);
|
const { container } = render(<ContactListItem contact={contact} {...baseProps} isSelected={true} />);
|
||||||
const button = container.querySelector('button');
|
const div = container.firstElementChild;
|
||||||
expect(button?.className).toContain('bg-accent');
|
expect(div?.className).toContain('bg-blue-200');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows email as display name when no name exists', () => {
|
it('shows email as display name when no name exists', () => {
|
||||||
render(<ContactListItem contact={noNameContact} isSelected={false} onClick={vi.fn()} />);
|
render(<ContactListItem contact={noNameContact} {...baseProps} />);
|
||||||
const matches = screen.getAllByText('nobody@example.com');
|
const matches = screen.getAllByText('nobody@example.com');
|
||||||
expect(matches.length).toBeGreaterThanOrEqual(1);
|
expect(matches.length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onClick when clicked', () => {
|
it('calls onClick when clicked', () => {
|
||||||
const onClick = vi.fn();
|
const onClick = vi.fn();
|
||||||
render(<ContactListItem contact={contact} isSelected={false} onClick={onClick} />);
|
render(<ContactListItem contact={contact} {...baseProps} onClick={onClick} />);
|
||||||
fireEvent.click(screen.getByText('Alice Smith'));
|
fireEvent.click(screen.getByText('Alice Smith'));
|
||||||
expect(onClick).toHaveBeenCalledOnce();
|
expect(onClick).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not show checkbox when hasSelection is false', () => {
|
||||||
|
const { container } = render(<ContactListItem contact={contact} {...baseProps} hasSelection={false} />);
|
||||||
|
expect(container.querySelector('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows checkbox when hasSelection is true', () => {
|
||||||
|
const { container } = render(<ContactListItem contact={contact} {...baseProps} hasSelection={true} />);
|
||||||
|
expect(container.querySelector('button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides avatar in extra-compact density', () => {
|
||||||
|
const { container } = render(<ContactListItem contact={contact} {...baseProps} density="extra-compact" />);
|
||||||
|
expect(container.querySelector('[data-testid="avatar"]') || container.querySelector('.rounded-full')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,15 +36,15 @@ const defaultProps = {
|
|||||||
onSearchChange: vi.fn(),
|
onSearchChange: vi.fn(),
|
||||||
onSelectContact: vi.fn(),
|
onSelectContact: vi.fn(),
|
||||||
onCreateNew: vi.fn(),
|
onCreateNew: vi.fn(),
|
||||||
supportsSync: true,
|
categoryLabel: 'All Contacts',
|
||||||
selectedContactIds: new Set<string>(),
|
selectedContactIds: new Set<string>(),
|
||||||
onToggleSelection: vi.fn(),
|
onToggleSelection: vi.fn(),
|
||||||
|
onSelectRangeContacts: vi.fn(),
|
||||||
onSelectAll: vi.fn(),
|
onSelectAll: vi.fn(),
|
||||||
onClearSelection: vi.fn(),
|
onClearSelection: vi.fn(),
|
||||||
onBulkDelete: vi.fn(),
|
onBulkDelete: vi.fn(),
|
||||||
onBulkAddToGroup: vi.fn(),
|
onBulkAddToGroup: vi.fn(),
|
||||||
onBulkExport: vi.fn(),
|
onBulkExport: vi.fn(),
|
||||||
groups: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('ContactList', () => {
|
describe('ContactList', () => {
|
||||||
@@ -70,32 +70,14 @@ describe('ContactList', () => {
|
|||||||
expect(screen.getByText('empty_search')).toBeInTheDocument();
|
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', () => {
|
it('shows bulk action bar when contacts are selected', () => {
|
||||||
render(<ContactList {...defaultProps} selectedContactIds={new Set(['1'])} />);
|
render(<ContactList {...defaultProps} selectedContactIds={new Set(['1'])} />);
|
||||||
expect(screen.getByText('bulk.delete')).toBeInTheDocument();
|
expect(screen.getByText('bulk.delete')).toBeInTheDocument();
|
||||||
expect(screen.getByText('bulk.export')).toBeInTheDocument();
|
expect(screen.getByText('bulk.export')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes groups from the list', () => {
|
it('shows category label with count', () => {
|
||||||
render(<ContactList {...defaultProps} contacts={[alice, bob, group]} />);
|
render(<ContactList {...defaultProps} />);
|
||||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
|
expect(screen.getByText('All Contacts (2)')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Team')).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ import { Avatar } from "@/components/ui/avatar";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
import type { ContactCard } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
|
import { CheckSquare, Square } from "lucide-react";
|
||||||
|
import type { Density } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface ContactListItemProps {
|
interface ContactListItemProps {
|
||||||
contact: ContactCard;
|
contact: ContactCard;
|
||||||
isSelected: boolean;
|
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 name = getContactDisplayName(contact);
|
||||||
const email = getContactPrimaryEmail(contact);
|
const email = getContactPrimaryEmail(contact);
|
||||||
const org = contact.organizations
|
const org = contact.organizations
|
||||||
@@ -19,27 +25,51 @@ export function ContactListItem({ contact, isSelected, onClick }: ContactListIte
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center px-4 text-left transition-colors",
|
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
||||||
"hover:bg-muted",
|
isSelected
|
||||||
isSelected && "bg-accent text-accent-foreground"
|
? "bg-blue-200 dark:bg-blue-900/50 shadow-sm"
|
||||||
|
: "bg-background hover:bg-muted hover:shadow-sm",
|
||||||
|
isChecked && !isSelected && "ring-2 ring-primary/20 bg-blue-100 dark:bg-blue-900/30",
|
||||||
)}
|
)}
|
||||||
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
|
style={{ gap: 'var(--density-item-gap)', paddingInline: '16px', paddingBlock: 'var(--density-item-py)' }}
|
||||||
>
|
>
|
||||||
<Avatar name={name} email={email} size="sm" />
|
{hasSelection && (
|
||||||
|
<button
|
||||||
|
onClick={onCheckboxClick}
|
||||||
|
className={cn(
|
||||||
|
"p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||||
|
"hover:bg-muted/50 hover:scale-110",
|
||||||
|
"active:scale-95",
|
||||||
|
"animate-in fade-in zoom-in-95 duration-150",
|
||||||
|
isChecked && "text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isChecked ? (
|
||||||
|
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
|
||||||
|
) : (
|
||||||
|
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{density !== 'extra-compact' && (
|
||||||
|
<Avatar name={name} email={email} size="sm" className="flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">
|
<div className="text-sm font-medium truncate">
|
||||||
{name || email || "—"}
|
{name || email || "—"}
|
||||||
</div>
|
</div>
|
||||||
{email && name && (
|
{density !== 'extra-compact' && email && name && (
|
||||||
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
<div className="text-xs text-muted-foreground truncate">{email}</div>
|
||||||
)}
|
)}
|
||||||
{org && (
|
{density === 'comfortable' && org && (
|
||||||
<div className="text-xs text-muted-foreground truncate">{org}</div>
|
<div className="text-xs text-muted-foreground truncate">{org}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ContactListItem } from "./contact-list-item";
|
import { ContactListItem } from "./contact-list-item";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ContactCard } from "@/lib/jmap/types";
|
import type { ContactCard } from "@/lib/jmap/types";
|
||||||
import { getContactDisplayName } from "@/stores/contact-store";
|
import { getContactDisplayName } from "@/stores/contact-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface ContactListProps {
|
interface ContactListProps {
|
||||||
contacts: ContactCard[];
|
contacts: ContactCard[];
|
||||||
@@ -17,10 +18,11 @@ interface ContactListProps {
|
|||||||
onSearchChange: (query: string) => void;
|
onSearchChange: (query: string) => void;
|
||||||
onSelectContact: (id: string) => void;
|
onSelectContact: (id: string) => void;
|
||||||
onCreateNew: () => void;
|
onCreateNew: () => void;
|
||||||
supportsSync: boolean;
|
categoryLabel: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
selectedContactIds: Set<string>;
|
selectedContactIds: Set<string>;
|
||||||
onToggleSelection: (id: string) => void;
|
onToggleSelection: (id: string) => void;
|
||||||
|
onSelectRangeContacts: (id: string, sortedIds: string[]) => void;
|
||||||
onSelectAll: (ids: string[]) => void;
|
onSelectAll: (ids: string[]) => void;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
onBulkDelete: () => void;
|
onBulkDelete: () => void;
|
||||||
@@ -35,10 +37,11 @@ export function ContactList({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
onSelectContact,
|
onSelectContact,
|
||||||
onCreateNew,
|
onCreateNew,
|
||||||
supportsSync,
|
categoryLabel,
|
||||||
className,
|
className,
|
||||||
selectedContactIds,
|
selectedContactIds,
|
||||||
onToggleSelection,
|
onToggleSelection,
|
||||||
|
onSelectRangeContacts,
|
||||||
onSelectAll,
|
onSelectAll,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
onBulkDelete,
|
onBulkDelete,
|
||||||
@@ -46,18 +49,27 @@ export function ContactList({
|
|||||||
onBulkExport,
|
onBulkExport,
|
||||||
}: ContactListProps) {
|
}: ContactListProps) {
|
||||||
const t = useTranslations("contacts");
|
const t = useTranslations("contacts");
|
||||||
|
const density = useSettingsStore((state) => state.density);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const individuals = contacts.filter(c => c.kind !== "group");
|
if (!searchQuery) return contacts;
|
||||||
if (!searchQuery) return individuals;
|
|
||||||
const lower = searchQuery.toLowerCase();
|
const lower = searchQuery.toLowerCase();
|
||||||
return individuals.filter((c) => {
|
return contacts.filter((c) => {
|
||||||
const name = getContactDisplayName(c).toLowerCase();
|
const name = getContactDisplayName(c).toLowerCase();
|
||||||
const emails = c.emails
|
const emails = c.emails
|
||||||
? Object.values(c.emails).map((e) => e.address.toLowerCase())
|
? 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 (
|
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]);
|
}, [contacts, searchQuery]);
|
||||||
@@ -70,41 +82,51 @@ export function ContactList({
|
|||||||
});
|
});
|
||||||
}, [filtered]);
|
}, [filtered]);
|
||||||
|
|
||||||
|
const sortedIds = useMemo(() => sorted.map(c => c.id), [sorted]);
|
||||||
|
|
||||||
const hasSelection = selectedContactIds.size > 0;
|
const hasSelection = selectedContactIds.size > 0;
|
||||||
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
|
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col h-full", className)}>
|
<div className={cn("flex flex-col h-full", className)}>
|
||||||
<div className="px-4 py-3 border-b border-border space-y-3">
|
{/* Search header */}
|
||||||
|
<div className="px-3 border-b border-border space-y-1.5" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
<span className="text-xs font-medium text-muted-foreground truncate">
|
||||||
<Button size="sm" onClick={onCreateNew}>
|
{categoryLabel} ({contacts.length})
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
</span>
|
||||||
{t("create_new")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("search_placeholder")}
|
placeholder={t("search_placeholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-8 h-8 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!supportsSync && (
|
|
||||||
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-muted rounded px-3 py-2">
|
|
||||||
<Info className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
|
||||||
<span>{t("local_mode")}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk action bar */}
|
||||||
{hasSelection && (
|
{hasSelection && (
|
||||||
<div className="px-3 py-2 border-b border-border bg-muted/50 flex items-center gap-2 flex-wrap">
|
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (allSelected) {
|
||||||
|
onClearSelection();
|
||||||
|
} else {
|
||||||
|
onSelectAll(sortedIds);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1 rounded hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
{allSelected ? (
|
||||||
|
<CheckSquare className="w-4 h-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Square className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-medium text-foreground">
|
||||||
{t("bulk.selected", { count: selectedContactIds.size })}
|
{t("bulk.selected", { count: selectedContactIds.size })}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
@@ -131,43 +153,19 @@ export function ContactList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sorted.length > 0 && (
|
{/* Contact list */}
|
||||||
<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">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
|
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
|
||||||
{searchQuery ? (
|
{searchQuery ? (
|
||||||
<>
|
<>
|
||||||
<Search className="w-12 h-12 mb-3 text-muted-foreground/30" />
|
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||||
<p className="text-sm font-medium text-foreground">{t("empty_search")}</p>
|
<p className="text-sm font-medium text-foreground">{t("empty_search")}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">{t("empty_search_hint")}</p>
|
<p className="text-xs text-muted-foreground mt-1">{t("empty_search_hint")}</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-4"
|
className="mt-3"
|
||||||
onClick={() => onSearchChange("")}
|
onClick={() => onSearchChange("")}
|
||||||
>
|
>
|
||||||
{t("clear_search")}
|
{t("clear_search")}
|
||||||
@@ -175,47 +173,43 @@ export function ContactList({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<BookUser className="w-12 h-12 mb-3 text-muted-foreground/30" />
|
<BookUser className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||||
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
|
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
|
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
|
||||||
<div className="flex gap-2 mt-4">
|
<Button size="sm" className="mt-3" onClick={onCreateNew}>
|
||||||
<Button size="sm" onClick={onCreateNew}>
|
<UserPlus className="w-4 h-4 mr-1.5" />
|
||||||
<UserPlus className="w-4 h-4 mr-1.5" />
|
{t("create_new")}
|
||||||
{t("create_new")}
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div>
|
||||||
{sorted.map((contact) => (
|
{sorted.map((contact) => (
|
||||||
<div key={contact.id} className="flex items-center">
|
<ContactListItem
|
||||||
<button
|
key={contact.id}
|
||||||
onClick={(e) => {
|
contact={contact}
|
||||||
e.stopPropagation();
|
isSelected={contact.id === selectedContactId}
|
||||||
|
isChecked={selectedContactIds.has(contact.id)}
|
||||||
|
hasSelection={hasSelection}
|
||||||
|
density={density}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
e.preventDefault();
|
||||||
onToggleSelection(contact.id);
|
onToggleSelection(contact.id);
|
||||||
}}
|
} else if (e.shiftKey) {
|
||||||
className="pl-4 pr-1 flex-shrink-0"
|
e.preventDefault();
|
||||||
style={{ paddingBlock: 'var(--density-item-py)' }}
|
onSelectRangeContacts(contact.id, sortedIds);
|
||||||
>
|
} else {
|
||||||
<div className={cn(
|
if (hasSelection) onClearSelection();
|
||||||
"w-4 h-4 rounded border flex items-center justify-center transition-colors",
|
onSelectContact(contact.id);
|
||||||
selectedContactIds.has(contact.id)
|
}
|
||||||
? "bg-primary border-primary text-primary-foreground"
|
}}
|
||||||
: "border-border hover:border-muted-foreground"
|
onCheckboxClick={(e) => {
|
||||||
)}>
|
e.stopPropagation();
|
||||||
{selectedContactIds.has(contact.id) && <Check className="w-2.5 h-2.5" />}
|
onToggleSelection(contact.id);
|
||||||
</div>
|
}}
|
||||||
</button>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<ContactListItem
|
|
||||||
contact={contact}
|
|
||||||
isSelected={contact.id === selectedContactId}
|
|
||||||
onClick={() => onSelectContact(contact.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className={cn("flex flex-col h-full bg-secondary", className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-3 border-b border-border flex items-center justify-between" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||||
|
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||||
|
<Button size="icon" variant="ghost" onClick={onCreateContact} className="h-7 w-7 flex-shrink-0">
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
|
{/* All contacts */}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectCategory("all")}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
|
||||||
|
isAllActive
|
||||||
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
|
: "text-foreground/80 hover:bg-muted"
|
||||||
|
)}
|
||||||
|
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||||
|
>
|
||||||
|
<BookUser className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate">{t("tabs.all")}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
|
{individuals.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Groups section */}
|
||||||
|
{(sortedGroups.length > 0) && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center justify-between px-3 py-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{t("tabs.groups")}
|
||||||
|
</span>
|
||||||
|
<Button size="icon" variant="ghost" onClick={onCreateGroup} className="h-5 w-5">
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
onClick={() => onSelectCategory({ groupId: group.id })}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
|
: "text-foreground/80 hover:bg-muted"
|
||||||
|
)}
|
||||||
|
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate">{getContactDisplayName(group)}</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||||
|
{memberCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sortedGroups.length === 0 && (
|
||||||
|
<div className="mt-2 px-3">
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
{t("tabs.groups")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onCreateGroup}
|
||||||
|
className="w-full justify-start text-xs text-muted-foreground h-7"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3 mr-1.5" />
|
||||||
|
{t("groups.create")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+21
-2
@@ -44,6 +44,7 @@ interface ContactStore {
|
|||||||
supportsSync: boolean;
|
supportsSync: boolean;
|
||||||
|
|
||||||
selectedContactIds: Set<string>;
|
selectedContactIds: Set<string>;
|
||||||
|
lastSelectedContactId: string | null;
|
||||||
activeTab: 'all' | 'groups';
|
activeTab: 'all' | 'groups';
|
||||||
|
|
||||||
fetchContacts: (client: JMAPClient) => Promise<void>;
|
fetchContacts: (client: JMAPClient) => Promise<void>;
|
||||||
@@ -74,6 +75,7 @@ interface ContactStore {
|
|||||||
deleteGroup: (client: JMAPClient | null, groupId: string) => Promise<void>;
|
deleteGroup: (client: JMAPClient | null, groupId: string) => Promise<void>;
|
||||||
|
|
||||||
toggleContactSelection: (id: string) => void;
|
toggleContactSelection: (id: string) => void;
|
||||||
|
selectRangeContacts: (targetId: string, sortedIds: string[]) => void;
|
||||||
selectAllContacts: (ids: string[]) => void;
|
selectAllContacts: (ids: string[]) => void;
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>;
|
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>;
|
||||||
@@ -93,6 +95,7 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
error: null,
|
error: null,
|
||||||
supportsSync: false,
|
supportsSync: false,
|
||||||
selectedContactIds: new Set<string>(),
|
selectedContactIds: new Set<string>(),
|
||||||
|
lastSelectedContactId: null,
|
||||||
activeTab: 'all' as const,
|
activeTab: 'all' as const,
|
||||||
|
|
||||||
fetchContacts: async (client) => {
|
fetchContacts: async (client) => {
|
||||||
@@ -382,12 +385,28 @@ export const useContactStore = create<ContactStore>()(
|
|||||||
} else {
|
} else {
|
||||||
next.add(id);
|
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) }),
|
selectAllContacts: (ids) => set({ selectedContactIds: new Set(ids) }),
|
||||||
|
|
||||||
clearSelection: () => set({ selectedContactIds: new Set<string>() }),
|
clearSelection: () => set({ selectedContactIds: new Set<string>(), lastSelectedContactId: null }),
|
||||||
|
|
||||||
bulkDeleteContacts: async (client, ids) => {
|
bulkDeleteContacts: async (client, ids) => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
|
|||||||
Reference in New Issue
Block a user