feat: add address book, fix email layout, update dependencies
- Address book with JMAP sync and local fallback (contacts CRUD, search/filter, composer autocomplete, i18n for 8 languages) - Fix email layout: remove horizontal scroll, left-side clipping, and empty spaces from blocked external images in newsletters - Update all dependencies to latest compatible versions - Expand i18n from 3 to 8 languages (added ES, IT, DE, NL, PT) - Upgrade Next.js to 16.1.6 for security patches
This commit is contained in:
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { useEmailStore } from './email-store';
|
||||
import { useIdentityStore } from './identity-store';
|
||||
import { useContactStore } from './contact-store';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
|
||||
interface AuthState {
|
||||
@@ -50,6 +51,16 @@ export const useAuthStore = create<AuthState>()(
|
||||
// Sync identities to identity store
|
||||
useIdentityStore.getState().setIdentities(identities);
|
||||
|
||||
// Fetch contacts if server supports JMAP Contacts
|
||||
if (client.supportsContacts()) {
|
||||
const contactStore = useContactStore.getState();
|
||||
contactStore.setSupportsSync(true);
|
||||
contactStore.fetchAddressBooks(client).catch(() => {});
|
||||
contactStore.fetchContacts(client).catch(() => {});
|
||||
} else {
|
||||
useContactStore.getState().setSupportsSync(false);
|
||||
}
|
||||
|
||||
// Success - save state (but NOT the password)
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
@@ -124,6 +135,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
// Clear identity store state
|
||||
useIdentityStore.getState().clearIdentities();
|
||||
|
||||
// Clear contact store state
|
||||
useContactStore.getState().clearContacts();
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
|
||||
import type { JMAPClient } from '@/lib/jmap/client';
|
||||
|
||||
function getContactDisplayName(contact: ContactCard): string {
|
||||
if (contact.name?.components) {
|
||||
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
|
||||
const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
|
||||
const full = [given, surname].filter(Boolean).join(' ');
|
||||
if (full) return full;
|
||||
}
|
||||
if (contact.nicknames) {
|
||||
const nick = Object.values(contact.nicknames)[0];
|
||||
if (nick?.name) return nick.name;
|
||||
}
|
||||
if (contact.emails) {
|
||||
const email = Object.values(contact.emails)[0];
|
||||
if (email?.address) return email.address;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getContactPrimaryEmail(contact: ContactCard): string {
|
||||
if (!contact.emails) return '';
|
||||
return Object.values(contact.emails)[0]?.address || '';
|
||||
}
|
||||
|
||||
interface ContactStore {
|
||||
contacts: ContactCard[];
|
||||
addressBooks: AddressBook[];
|
||||
selectedContactId: string | null;
|
||||
searchQuery: string;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
supportsSync: boolean;
|
||||
|
||||
fetchContacts: (client: JMAPClient) => Promise<void>;
|
||||
fetchAddressBooks: (client: JMAPClient) => Promise<void>;
|
||||
createContact: (client: JMAPClient, contact: Partial<ContactCard>) => Promise<void>;
|
||||
updateContact: (client: JMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
|
||||
deleteContact: (client: JMAPClient, id: string) => Promise<void>;
|
||||
|
||||
addLocalContact: (contact: ContactCard) => void;
|
||||
updateLocalContact: (id: string, updates: Partial<ContactCard>) => void;
|
||||
deleteLocalContact: (id: string) => void;
|
||||
|
||||
setSelectedContact: (id: string | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setSupportsSync: (supports: boolean) => void;
|
||||
clearContacts: () => void;
|
||||
|
||||
getAutocomplete: (query: string) => Array<{ name: string; email: string }>;
|
||||
}
|
||||
|
||||
export const useContactStore = create<ContactStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
contacts: [],
|
||||
addressBooks: [],
|
||||
selectedContactId: null,
|
||||
searchQuery: '',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
supportsSync: false,
|
||||
|
||||
fetchContacts: async (client) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const contacts = await client.getContacts();
|
||||
set({ contacts, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch contacts:', error);
|
||||
set({ error: 'Failed to fetch contacts', isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchAddressBooks: async (client) => {
|
||||
try {
|
||||
const addressBooks = await client.getAddressBooks();
|
||||
set({ addressBooks });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch address books:', error);
|
||||
}
|
||||
},
|
||||
|
||||
createContact: async (client, contact) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const created = await client.createContact(contact);
|
||||
set((state) => ({
|
||||
contacts: [...state.contacts, created],
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to create contact';
|
||||
set({ error: msg, isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateContact: async (client, id, updates) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
await client.updateContact(id, updates);
|
||||
set((state) => ({
|
||||
contacts: state.contacts.map(c =>
|
||||
c.id === id ? { ...c, ...updates } : c
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to update contact';
|
||||
set({ error: msg });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteContact: async (client, id) => {
|
||||
set({ error: null });
|
||||
try {
|
||||
await client.deleteContact(id);
|
||||
set((state) => ({
|
||||
contacts: state.contacts.filter(c => c.id !== id),
|
||||
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
||||
}));
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to delete contact';
|
||||
set({ error: msg });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
addLocalContact: (contact) => set((state) => ({
|
||||
contacts: [...state.contacts, contact],
|
||||
})),
|
||||
|
||||
updateLocalContact: (id, updates) => set((state) => ({
|
||||
contacts: state.contacts.map(c =>
|
||||
c.id === id ? { ...c, ...updates } : c
|
||||
),
|
||||
})),
|
||||
|
||||
deleteLocalContact: (id) => set((state) => ({
|
||||
contacts: state.contacts.filter(c => c.id !== id),
|
||||
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
||||
})),
|
||||
|
||||
setSelectedContact: (id) => set({ selectedContactId: id }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setSupportsSync: (supports) => set({ supportsSync: supports }),
|
||||
|
||||
clearContacts: () => set({
|
||||
contacts: [],
|
||||
addressBooks: [],
|
||||
selectedContactId: null,
|
||||
searchQuery: '',
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getAutocomplete: (query) => {
|
||||
const { contacts } = get();
|
||||
if (!query || query.length < 1) return [];
|
||||
|
||||
const lower = query.toLowerCase();
|
||||
const results: Array<{ name: string; email: string }> = [];
|
||||
|
||||
for (const contact of contacts) {
|
||||
const name = getContactDisplayName(contact);
|
||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||
|
||||
for (const emailEntry of emails) {
|
||||
if (!emailEntry.address) continue;
|
||||
if (
|
||||
name.toLowerCase().includes(lower) ||
|
||||
emailEntry.address.toLowerCase().includes(lower)
|
||||
) {
|
||||
results.push({ name, email: emailEntry.address });
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length >= 10) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'contact-storage',
|
||||
partialize: (state) => ({
|
||||
contacts: state.supportsSync ? [] : state.contacts,
|
||||
supportsSync: state.supportsSync,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export { getContactDisplayName, getContactPrimaryEmail };
|
||||
export type { ContactName };
|
||||
Reference in New Issue
Block a user