feat: add contacts settings with import/export functionality and update UI components

This commit is contained in:
Linus Rath
2026-03-16 02:18:22 +01:00
parent 37b0bacccb
commit e02477323f
5 changed files with 102 additions and 73 deletions
+1 -62
View File
@@ -3,7 +3,7 @@
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, Upload, Download, Users, BookUser } from "lucide-react"; import { ArrowLeft, Users, BookUser } 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";
@@ -13,7 +13,6 @@ import { ContactForm } from "@/components/contacts/contact-form";
import { ContactGroupList } from "@/components/contacts/contact-group-list"; 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 { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
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";
@@ -32,7 +31,6 @@ type View =
| "group-detail" | "group-detail"
| "group-create" | "group-create"
| "group-edit" | "group-edit"
| "import"
| "bulk-add-to-group"; | "bulk-add-to-group";
export default function ContactsPage() { export default function ContactsPage() {
@@ -69,7 +67,6 @@ export default function ContactsPage() {
clearSelection, clearSelection,
bulkDeleteContacts, bulkDeleteContacts,
bulkAddToGroup, bulkAddToGroup,
importContacts,
} = useContactStore(); } = useContactStore();
const [view, setView] = useState<View>("list"); const [view, setView] = useState<View>("list");
@@ -177,8 +174,6 @@ export default function ContactsPage() {
const handleCancel = () => { const handleCancel = () => {
if (view === "group-create" || view === "group-edit") { if (view === "group-create" || view === "group-edit") {
setView(selectedGroup ? "group-detail" : "list"); setView(selectedGroup ? "group-detail" : "list");
} else if (view === "import") {
setView("list");
} else if (view === "bulk-add-to-group") { } else if (view === "bulk-add-to-group") {
setView("list"); setView("list");
} else { } else {
@@ -313,13 +308,6 @@ export default function ContactsPage() {
} }
}; };
const handleImport = useCallback(async (importedContacts: ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
importedContacts
);
}, [supportsSync, client, importContacts]);
if (!isAuthenticated) return null; if (!isAuthenticated) return null;
const renderRightPanel = () => { const renderRightPanel = () => {
@@ -376,15 +364,6 @@ export default function ContactsPage() {
/> />
); );
case "import":
return (
<ContactImportDialog
existingContacts={contacts}
onImport={handleImport}
onClose={handleCancel}
/>
);
case "bulk-add-to-group": case "bulk-add-to-group":
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -467,45 +446,6 @@ export default function ContactsPage() {
"border-r border-border flex flex-col flex-shrink-0", "border-r border-border flex flex-col flex-shrink-0",
isMobile ? "w-full" : "w-80" isMobile ? "w-full" : "w-80"
)}> )}>
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/")}
className="justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")}
</Button>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setView("import")}
title={t("import.title")}
>
<Upload className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
if (contacts.length > 0) {
exportContacts(contacts.filter(c => c.kind !== "group"));
toast.success(t("export.success", { count: contacts.filter(c => c.kind !== "group").length }));
}
}}
title={t("export.title")}
>
<Download className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="flex border-b border-border"> <div className="flex border-b border-border">
<button <button
onClick={() => setActiveTab("all")} onClick={() => setActiveTab("all")}
@@ -546,7 +486,6 @@ export default function ContactsPage() {
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact} onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew} onCreateNew={handleCreateNew}
onImport={() => setView("import")}
supportsSync={supportsSync} supportsSync={supportsSync}
className="flex-1" className="flex-1"
selectedContactIds={selectedContactIds} selectedContactIds={selectedContactIds}
+6 -1
View File
@@ -21,6 +21,7 @@ import {
Tags, Tags,
HardDrive, HardDrive,
Wrench, Wrench,
BookUser,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -38,6 +39,7 @@ import { FolderSettings } from '@/components/settings/folder-settings';
import { KeywordSettings } from '@/components/settings/keyword-settings'; import { KeywordSettings } from '@/components/settings/keyword-settings';
import { AccountSecuritySettings } from '@/components/settings/account-security-settings'; import { AccountSecuritySettings } from '@/components/settings/account-security-settings';
import { FilesSettingsComponent } from '@/components/settings/files-settings'; import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store'; import { useEmailStore } from '@/stores/email-store';
import { useIsDesktop } from '@/hooks/use-media-query'; import { useIsDesktop } from '@/hooks/use-media-query';
@@ -45,7 +47,7 @@ import { NavigationRail } from '@/components/layout/navigation-rail';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced'; type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced';
type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system'; type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system';
interface TabDef { interface TabDef {
@@ -63,6 +65,7 @@ const tabIcons: Record<Tab, LucideIcon> = {
identities: UserPen, identities: UserPen,
vacation: PalmtreeIcon, vacation: PalmtreeIcon,
calendar: Calendar, calendar: Calendar,
contacts: BookUser,
filters: Filter, filters: Filter,
templates: FileText, templates: FileText,
folders: FolderOpen, folders: FolderOpen,
@@ -126,6 +129,7 @@ export default function SettingsPage() {
{ id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' }, { id: 'folders', label: t('tabs.folders'), icon: tabIcons.folders, group: 'organization' },
{ id: 'keywords', label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' }, { id: 'keywords', label: t('tabs.keywords'), icon: tabIcons.keywords, group: 'organization' },
...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []), ...(supportsCalendar ? [{ id: 'calendar' as Tab, label: t('tabs.calendar'), icon: tabIcons.calendar, group: 'apps' as TabGroup }] : []),
{ id: 'contacts', label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' },
...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []), ...(supportsFiles ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
{ id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' }, { id: 'advanced', label: t('tabs.advanced'), icon: tabIcons.advanced, group: 'system' },
]; ];
@@ -158,6 +162,7 @@ export default function SettingsPage() {
{activeTab === 'identities' && <IdentitySettings />} {activeTab === 'identities' && <IdentitySettings />}
{activeTab === 'vacation' && <VacationSettings />} {activeTab === 'vacation' && <VacationSettings />}
{activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>} {activeTab === 'calendar' && <><CalendarSettings /><div className="mt-8"><CalendarManagementSettings /></div></>}
{activeTab === 'contacts' && <ContactsSettings />}
{activeTab === 'filters' && <FilterSettings />} {activeTab === 'filters' && <FilterSettings />}
{activeTab === 'templates' && <TemplateSettings />} {activeTab === 'templates' && <TemplateSettings />}
{activeTab === 'folders' && <FolderSettings />} {activeTab === 'folders' && <FolderSettings />}
+1 -9
View File
@@ -2,7 +2,7 @@
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, Upload } from "lucide-react"; import { Search, Plus, BookUser, Info, Check, Trash2, Users, Download, X, UserPlus } 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";
@@ -17,7 +17,6 @@ interface ContactListProps {
onSearchChange: (query: string) => void; onSearchChange: (query: string) => void;
onSelectContact: (id: string) => void; onSelectContact: (id: string) => void;
onCreateNew: () => void; onCreateNew: () => void;
onImport?: () => void;
supportsSync: boolean; supportsSync: boolean;
className?: string; className?: string;
selectedContactIds: Set<string>; selectedContactIds: Set<string>;
@@ -36,7 +35,6 @@ export function ContactList({
onSearchChange, onSearchChange,
onSelectContact, onSelectContact,
onCreateNew, onCreateNew,
onImport,
supportsSync, supportsSync,
className, className,
selectedContactIds, selectedContactIds,
@@ -185,12 +183,6 @@ export function ContactList({
<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>
{onImport && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 mr-1.5" />
{t("import_vcard")}
</Button>
)}
</div> </div>
</> </>
)} )}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Upload, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem } from "./settings-section";
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
export function ContactsSettings() {
const t = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const {
contacts,
supportsSync,
importContacts,
} = useContactStore();
const [showImport, setShowImport] = useState(false);
const individuals = contacts.filter(c => c.kind !== "group");
const handleImport = useCallback(async (importedContacts: import("@/lib/jmap/types").ContactCard[]) => {
return importContacts(
supportsSync && client ? client : null,
importedContacts
);
}, [supportsSync, client, importContacts]);
const handleExport = () => {
if (individuals.length > 0) {
exportContacts(individuals);
toast.success(t("export.success", { count: individuals.length }));
}
};
if (showImport) {
return (
<div className="border border-border rounded-lg overflow-hidden" style={{ minHeight: 400 }}>
<ContactImportDialog
existingContacts={contacts}
onImport={handleImport}
onClose={() => setShowImport(false)}
/>
</div>
);
}
return (
<SettingsSection
title={tSettings("title")}
description={tSettings("description")}
>
<SettingItem
label={tSettings("import_label")}
description={tSettings("import_description")}
>
<Button variant="outline" size="sm" onClick={() => setShowImport(true)}>
<Upload className="w-4 h-4 mr-2" />
{t("import.title")}
</Button>
</SettingItem>
<SettingItem
label={tSettings("export_label")}
description={tSettings("export_description")}
>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={individuals.length === 0}
>
<Download className="w-4 h-4 mr-2" />
{t("export.title")}
</Button>
</SettingItem>
</SettingsSection>
);
}
+10 -1
View File
@@ -500,7 +500,8 @@
"folders": "Folders", "folders": "Folders",
"keywords": "Keywords", "keywords": "Keywords",
"security": "Security", "security": "Security",
"files": "Files" "files": "Files",
"contacts": "Contacts"
}, },
"tab_groups": { "tab_groups": {
"general": "General", "general": "General",
@@ -917,6 +918,14 @@
"button": "Import" "button": "Import"
} }
}, },
"contacts": {
"title": "Contacts",
"description": "Import and export your contacts",
"import_label": "Import Contacts",
"import_description": "Import contacts from a vCard (.vcf) file",
"export_label": "Export Contacts",
"export_description": "Export all contacts as a vCard (.vcf) file"
},
"filters": { "filters": {
"title": "Email Filters", "title": "Email Filters",
"description": "Create rules to automatically sort, label, and manage incoming emails", "description": "Create rules to automatically sort, label, and manage incoming emails",