feat: add "New address book" creation UI #415
This commit is contained in:
@@ -84,6 +84,7 @@ export default function ContactsPage() {
|
||||
bulkDeleteContacts,
|
||||
bulkAddToGroup,
|
||||
moveContactToAddressBook,
|
||||
createAddressBook,
|
||||
renameAddressBook,
|
||||
removeAddressBook,
|
||||
shareAddressBook,
|
||||
@@ -95,6 +96,7 @@ export default function ContactsPage() {
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
|
||||
const [creatingAddressBook, setCreatingAddressBook] = useState(false);
|
||||
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(null);
|
||||
const [defaultBookIdForCreate, setDefaultBookIdForCreate] = useState<string | undefined>(undefined);
|
||||
const [createPrefill, setCreatePrefill] = useState<{ email?: string; name?: string } | undefined>(undefined);
|
||||
@@ -300,6 +302,34 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [client, supportsSync, contacts, updateContact, updateLocalContact, t]);
|
||||
|
||||
// Refresh address books (and contacts) after a structural change, staying
|
||||
// multi-account aware so a freshly created book lands in the sidebar.
|
||||
const refreshAddressBooks = useCallback(async () => {
|
||||
if (!client) return;
|
||||
if (multiAccountEnabled && accountClients.length > 0) {
|
||||
const activeId = useAuthStore.getState().activeAccountId;
|
||||
if (activeId) {
|
||||
const { fetchAllAccountsAddressBooks } = useContactStore.getState();
|
||||
await fetchAllAccountsAddressBooks(accountClients, activeId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await useContactStore.getState().fetchAddressBooks(client);
|
||||
}, [client, multiAccountEnabled, accountClients]);
|
||||
|
||||
const handleCreateAddressBook = useCallback(async (name: string) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
await createAddressBook(client, name);
|
||||
await refreshAddressBooks();
|
||||
toast.success(t("address_books.created"));
|
||||
setCreatingAddressBook(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create address book:', error);
|
||||
toast.error(t("address_books.create_failed"));
|
||||
}
|
||||
}, [client, createAddressBook, refreshAddressBooks, t]);
|
||||
|
||||
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||
return importContacts(
|
||||
supportsSync && client ? client : null,
|
||||
@@ -857,6 +887,7 @@ export default function ContactsPage() {
|
||||
onSelectCategory={handleSelectCategory}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
onCreateContact={handleCreateNew}
|
||||
onCreateAddressBook={client ? () => setCreatingAddressBook(true) : undefined}
|
||||
onImport={() => setShowImportDialog(true)}
|
||||
onEditGroup={handleEditGroupFromSidebar}
|
||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||
@@ -1006,6 +1037,15 @@ export default function ContactsPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{creatingAddressBook && (
|
||||
<RenameDialog
|
||||
currentName=""
|
||||
title={t("address_books.create")}
|
||||
label={t("address_books.name_label")}
|
||||
onCancel={() => setCreatingAddressBook(false)}
|
||||
onConfirm={handleCreateAddressBook}
|
||||
/>
|
||||
)}
|
||||
{renamingAddressBook && (
|
||||
<RenameDialog
|
||||
currentName={renamingAddressBook.name}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail } from "lucide-react";
|
||||
import { BookUser, User, Users, Plus, Share2, Book, BookPlus, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings, Mail } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
|
||||
@@ -22,6 +22,7 @@ interface ContactsSidebarProps {
|
||||
onSelectCategory: (category: ContactCategory) => void;
|
||||
onCreateGroup: () => void;
|
||||
onCreateContact: () => void;
|
||||
onCreateAddressBook?: () => void;
|
||||
onImport?: () => void;
|
||||
onEditGroup?: (groupId: string) => void;
|
||||
onDeleteGroup?: (groupId: string) => void;
|
||||
@@ -91,6 +92,7 @@ export function ContactsSidebar({
|
||||
onSelectCategory,
|
||||
onCreateGroup,
|
||||
onCreateContact,
|
||||
onCreateAddressBook,
|
||||
onImport,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
@@ -298,6 +300,15 @@ export function ContactsSidebar({
|
||||
<UsersRound className="w-4 h-4" />
|
||||
{t("groups.create")}
|
||||
</button>
|
||||
{onCreateAddressBook && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
|
||||
>
|
||||
<BookPlus className="w-4 h-4" />
|
||||
{t("address_books.create")}
|
||||
</button>
|
||||
)}
|
||||
{onImport && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
|
||||
import { Book, BookPlus, Pencil, Share2, Tag, Users } from "lucide-react";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useManagedAccountStore } from "@/stores/managed-account-store";
|
||||
@@ -11,6 +11,7 @@ import { SettingsSection } from "./settings-section";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "./share-collection-dialog";
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
|
||||
function AddressBookEditRow({
|
||||
initial,
|
||||
@@ -73,10 +74,11 @@ export function AddressBookManagementSettings() {
|
||||
const tSettings = useTranslations("settings.contacts");
|
||||
const { client } = useAuthStore();
|
||||
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
|
||||
const { addressBooks, contacts, supportsSync, fetchAddressBooks, createAddressBook, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -85,6 +87,21 @@ export function AddressBookManagementSettings() {
|
||||
}
|
||||
}, [client, addressBooks.length, fetchAddressBooks]);
|
||||
|
||||
const handleCreate = async (name: string) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await createAddressBook(client, name);
|
||||
await fetchAddressBooks(client);
|
||||
setCreating(false);
|
||||
toast.success(t("created"));
|
||||
} catch {
|
||||
toast.error(t("create_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (book: AddressBook, newName: string) => {
|
||||
if (!client) return;
|
||||
setIsLoading(true);
|
||||
@@ -226,6 +243,19 @@ export function AddressBookManagementSettings() {
|
||||
{addressBooks.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">{tSettings("no_address_books")}</p>
|
||||
)}
|
||||
|
||||
{/* Creating targets the user's own account, so hide it while scoped to a
|
||||
managed (shared) account. */}
|
||||
{!managedAccountId && client && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
className="flex items-center gap-2 py-2.5 px-3 w-full rounded-md border border-dashed border-border text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<BookPlus className="w-4 h-4 flex-shrink-0" />
|
||||
{t("create")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -278,6 +308,16 @@ export function AddressBookManagementSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creating && (
|
||||
<RenameDialog
|
||||
currentName=""
|
||||
title={t("create")}
|
||||
label={t("name_label")}
|
||||
onCancel={() => setCreating(false)}
|
||||
onConfirm={handleCreate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sharingId && client && (() => {
|
||||
const book = addressBooks.find((b) => b.id === sharingId);
|
||||
if (!book) return null;
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "Sdílené"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nový adresář",
|
||||
"created": "Adresář vytvořen",
|
||||
"create_failed": "Nepodařilo se vytvořit adresář",
|
||||
"title": "Moje adresáře",
|
||||
"shared_prefix": "Sdílené: {name}",
|
||||
"moved": "Kontakt přesunut do {name}",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "Delt"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Ny adressebog",
|
||||
"created": "Adressebog oprettet",
|
||||
"create_failed": "Kunne ikke oprette adressebog",
|
||||
"title": "Mine adressebøger",
|
||||
"shared_prefix": "Delt: {name}",
|
||||
"moved": "Kontakt flyttet til {name}",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "Geteilt"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Neues Adressbuch",
|
||||
"created": "Adressbuch erstellt",
|
||||
"create_failed": "Adressbuch konnte nicht erstellt werden",
|
||||
"title": "Meine Adressbücher",
|
||||
"shared_prefix": "Geteilt: {name}",
|
||||
"moved": "Kontakt verschoben nach {name}",
|
||||
|
||||
@@ -2173,6 +2173,9 @@
|
||||
"title": "Shared"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "New address book",
|
||||
"created": "Address book created",
|
||||
"create_failed": "Failed to create address book",
|
||||
"title": "My Address Books",
|
||||
"shared_prefix": "Shared: {name}",
|
||||
"moved": "Contact moved to {name}",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "Compartidos"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nueva libreta de direcciones",
|
||||
"created": "Libreta de direcciones creada",
|
||||
"create_failed": "No se pudo crear la libreta de direcciones",
|
||||
"title": "Mis Libretas de Direcciones",
|
||||
"shared_prefix": "Compartido: {name}",
|
||||
"moved": "Contacto movido a {name}",
|
||||
|
||||
@@ -2173,6 +2173,9 @@
|
||||
"title": "اشتراکی"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "دفترچه آدرس جدید",
|
||||
"created": "دفترچه آدرس ایجاد شد",
|
||||
"create_failed": "ایجاد دفترچه آدرس ناموفق بود",
|
||||
"title": "دفترچههای آدرس من",
|
||||
"shared_prefix": "اشتراکی: {name}",
|
||||
"moved": "مخاطب به {name} منتقل شد",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "Partagés"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nouveau carnet d'adresses",
|
||||
"created": "Carnet d'adresses créé",
|
||||
"create_failed": "Échec de la création du carnet d'adresses",
|
||||
"title": "Mes Carnets d'adresses",
|
||||
"shared_prefix": "Partagé : {name}",
|
||||
"moved": "Contact déplacé vers {name}",
|
||||
|
||||
@@ -2173,6 +2173,9 @@
|
||||
"title": "Megosztott"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Új címjegyzék",
|
||||
"created": "Címjegyzék létrehozva",
|
||||
"create_failed": "Nem sikerült létrehozni a címjegyzéket",
|
||||
"title": "Címjegyzékeim",
|
||||
"shared_prefix": "Megosztott: {name}",
|
||||
"moved": "Névjegy áthelyezve ide: {name}",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "Condivisi"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nuova rubrica",
|
||||
"created": "Rubrica creata",
|
||||
"create_failed": "Impossibile creare la rubrica",
|
||||
"title": "Le mie Rubriche",
|
||||
"shared_prefix": "Condiviso: {name}",
|
||||
"moved": "Contatto spostato in {name}",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "共有"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "新しいアドレス帳",
|
||||
"created": "アドレス帳を作成しました",
|
||||
"create_failed": "アドレス帳の作成に失敗しました",
|
||||
"title": "マイアドレス帳",
|
||||
"shared_prefix": "共有: {name}",
|
||||
"moved": "連絡先を {name} に移動しました",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "공유됨"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "새 주소록",
|
||||
"created": "주소록이 생성되었습니다",
|
||||
"create_failed": "주소록을 만들지 못했습니다",
|
||||
"title": "내 주소록",
|
||||
"shared_prefix": "공유됨: {name}",
|
||||
"moved": "연락처가 {name}(으)로 이동되었어요",
|
||||
|
||||
@@ -2168,6 +2168,9 @@
|
||||
"title": "Koplietotie"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Jauna adrešu grāmata",
|
||||
"created": "Adrešu grāmata izveidota",
|
||||
"create_failed": "Neizdevās izveidot adrešu grāmatu",
|
||||
"title": "Manas adrešu grāmatas",
|
||||
"shared_prefix": "Koplietotā: {name}",
|
||||
"moved": "Kontakts pārvietots uz {name}",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "Gedeeld"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nieuw adresboek",
|
||||
"created": "Adresboek aangemaakt",
|
||||
"create_failed": "Kan adresboek niet aanmaken",
|
||||
"title": "Mijn Adresboeken",
|
||||
"shared_prefix": "Gedeeld: {name}",
|
||||
"moved": "Contact verplaatst naar {name}",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "Udostępnione"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nowa książka adresowa",
|
||||
"created": "Utworzono książkę adresową",
|
||||
"create_failed": "Nie udało się utworzyć książki adresowej",
|
||||
"title": "Moje książki adresowe",
|
||||
"shared_prefix": "Udostępnione: {name}",
|
||||
"moved": "Kontakt przeniesiono do {name}",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "Compartilhados"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Novo livro de endereços",
|
||||
"created": "Livro de endereços criado",
|
||||
"create_failed": "Falha ao criar o livro de endereços",
|
||||
"title": "Meus Catálogos de Endereços",
|
||||
"shared_prefix": "Compartilhado: {name}",
|
||||
"moved": "Contato movido para {name}",
|
||||
|
||||
@@ -2173,6 +2173,9 @@
|
||||
"title": "Partajat"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Agendă nouă",
|
||||
"created": "Agendă creată",
|
||||
"create_failed": "Nu s-a putut crea agenda",
|
||||
"title": "Agendele mele de adrese",
|
||||
"shared_prefix": "Partajat: {name}",
|
||||
"moved": "Contactul a fost mutat la {name}",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "Общий"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Новая адресная книга",
|
||||
"created": "Адресная книга создана",
|
||||
"create_failed": "Не удалось создать адресную книгу",
|
||||
"title": "Мои адресные книги",
|
||||
"shared_prefix": "Общая: {name}",
|
||||
"moved": "Контакт перемещён в {name}",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "Paylaşılan"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Yeni adres defteri",
|
||||
"created": "Adres defteri oluşturuldu",
|
||||
"create_failed": "Adres defteri oluşturulamadı",
|
||||
"title": "Adres Defterlerim",
|
||||
"shared_prefix": "Paylaşılan: {name}",
|
||||
"moved": "Kişi {name} konumuna taşındı",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "Спільний доступ"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Нова адресна книга",
|
||||
"created": "Адресну книгу створено",
|
||||
"create_failed": "Не вдалося створити адресну книгу",
|
||||
"title": "Мої адресні книги",
|
||||
"shared_prefix": "Спільно: {name}",
|
||||
"moved": "Контакт переміщено до {name}",
|
||||
|
||||
@@ -2172,6 +2172,9 @@
|
||||
"title": "共享"
|
||||
},
|
||||
"address_books": {
|
||||
"create": "新建通讯录",
|
||||
"created": "通讯录已创建",
|
||||
"create_failed": "创建通讯录失败",
|
||||
"title": "我的地址簿",
|
||||
"shared_prefix": "共享:{name}",
|
||||
"moved": "联系人已移至 {name}",
|
||||
|
||||
@@ -201,6 +201,7 @@ interface ContactStore {
|
||||
bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<void>;
|
||||
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
|
||||
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
|
||||
createAddressBook: (client: IJMAPClient, name: string) => Promise<AddressBook>;
|
||||
renameAddressBook: (client: IJMAPClient, addressBook: AddressBook, newName: string) => Promise<void>;
|
||||
removeAddressBook: (client: IJMAPClient, addressBook: AddressBook) => Promise<void>;
|
||||
shareAddressBook: (client: IJMAPClient, addressBook: AddressBook, principalId: string, rights: AddressBookRights | null) => Promise<void>;
|
||||
@@ -849,6 +850,22 @@ export const useContactStore = create<ContactStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
createAddressBook: async (client, name) => {
|
||||
set({ error: null });
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error('Address book name is required');
|
||||
try {
|
||||
// New books always belong to the active account; the caller refreshes
|
||||
// the list afterwards (single- or multi-account aware) so the freshly
|
||||
// created book lands in state with its full server-set properties.
|
||||
return await client.createAddressBook(trimmed);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to create address book';
|
||||
set({ error: msg });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
renameAddressBook: async (client, addressBook, newName) => {
|
||||
set({ error: null });
|
||||
const trimmed = newName.trim();
|
||||
|
||||
Reference in New Issue
Block a user