From fc3129aaa5235b53f75526a94bb79806d77bc0ad Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:31:36 +0100 Subject: [PATCH] refactor(contacts): major component and API refactoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Components: - Simplify ContactDetailModal and NewContactModal - Enhance ContactsToolbarContent with expanded functionality - Add AlphabetNavContextMenu for alphabet navigation - Add SocialMediaLinks component for displaying social links - Add SocialMediaFields form component - Add ContactNetworkView as integrated network visualization - Improve skeleton components with shared utilities API & Config: - Add centralized API client module - Refactor contacts API with better error handling - Add social-media configuration module - Update batch and config modules Stores: - Simplify filter store - Update settings and user-settings stores - Clean up view-mode store - Minor auth store updates Routes: - Update layout with simplified navigation - Minor updates to settings, statistics pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/contacts/apps/web/package.json | 5 +- apps/contacts/apps/web/src/lib/api/batch.ts | 28 +- apps/contacts/apps/web/src/lib/api/client.ts | 71 +++ apps/contacts/apps/web/src/lib/api/config.ts | 10 +- .../contacts/apps/web/src/lib/api/contacts.ts | 138 +++-- .../components/AlphabetNavContextMenu.svelte | 86 +++ .../lib/components/ContactDetailModal.svelte | 530 +----------------- .../web/src/lib/components/ContactList.svelte | 50 +- .../components/ContactsToolbarContent.svelte | 433 +++++++++++--- .../src/lib/components/NewContactModal.svelte | 286 +--------- .../web/src/lib/components/SearchModal.svelte | 23 +- .../lib/components/SocialMediaLinks.svelte | 151 +++++ .../components/forms/SocialMediaFields.svelte | 369 ++++++++++++ .../components/network/NetworkGraph.svelte | 6 +- .../skeletons/ContactGridSkeleton.svelte | 9 +- .../skeletons/ContactListSkeleton.svelte | 9 +- .../skeletons/DuplicateListSkeleton.svelte | 12 +- .../skeletons/TagGridSkeleton.svelte | 9 +- .../web/src/lib/components/skeletons/index.ts | 7 +- .../web/src/lib/components/skeletons/utils.ts | 19 + .../views/ContactAlphabetView.svelte | 89 ++- .../components/views/ContactGridView.svelte | 4 +- .../views/ContactNetworkView.svelte | 257 +++++++++ .../apps/web/src/lib/config/social-media.ts | 168 ++++++ .../apps/web/src/lib/services/feedback.ts | 3 +- .../apps/web/src/lib/stores/auth.svelte.ts | 5 +- .../apps/web/src/lib/stores/filter.svelte.ts | 67 +-- .../web/src/lib/stores/settings.svelte.ts | 32 +- .../src/lib/stores/user-settings.svelte.ts | 3 +- .../web/src/lib/stores/view-mode.svelte.ts | 11 +- .../apps/web/src/routes/(app)/+layout.svelte | 59 +- .../src/routes/(app)/settings/+page.svelte | 3 +- .../src/routes/(app)/statistics/+page.svelte | 23 +- .../(auth)/forgot-password/+page.svelte | 4 +- 34 files changed, 1808 insertions(+), 1171 deletions(-) create mode 100644 apps/contacts/apps/web/src/lib/api/client.ts create mode 100644 apps/contacts/apps/web/src/lib/components/AlphabetNavContextMenu.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/SocialMediaLinks.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/forms/SocialMediaFields.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/skeletons/utils.ts create mode 100644 apps/contacts/apps/web/src/lib/components/views/ContactNetworkView.svelte create mode 100644 apps/contacts/apps/web/src/lib/config/social-media.ts diff --git a/apps/contacts/apps/web/package.json b/apps/contacts/apps/web/package.json index f274af7a9..735a53d38 100644 --- a/apps/contacts/apps/web/package.json +++ b/apps/contacts/apps/web/package.json @@ -31,8 +31,6 @@ }, "dependencies": { "@manacore/shared-auth": "workspace:*", - "@manacore/shared-splitscreen": "workspace:*", - "@manacore/shared-tags": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-feedback-service": "workspace:*", @@ -43,7 +41,9 @@ "@manacore/shared-i18n": "workspace:*", "@manacore/shared-icons": "workspace:*", "@manacore/shared-profile-ui": "workspace:*", + "@manacore/shared-splitscreen": "workspace:*", "@manacore/shared-subscription-ui": "workspace:*", + "@manacore/shared-tags": "workspace:*", "@manacore/shared-tailwind": "workspace:*", "@manacore/shared-theme": "workspace:*", "@manacore/shared-theme-ui": "workspace:*", @@ -52,6 +52,7 @@ "d3-force": "^3.0.0", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0", + "date-fns": "^4.1.0", "lucide-svelte": "^0.556.0", "svelte-i18n": "^4.0.1" }, diff --git a/apps/contacts/apps/web/src/lib/api/batch.ts b/apps/contacts/apps/web/src/lib/api/batch.ts index 5307425ee..2fcf3b956 100644 --- a/apps/contacts/apps/web/src/lib/api/batch.ts +++ b/apps/contacts/apps/web/src/lib/api/batch.ts @@ -1,30 +1,4 @@ -import { authStore } from '$lib/stores/auth.svelte'; -import { API_BASE } from './config'; - -async function fetchWithAuth(url: string, options: RequestInit = {}) { - const token = await authStore.getAccessToken(); - - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...(options.headers || {}), - }; - - if (token) { - (headers as Record)['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(`${API_BASE}${url}`, { - ...options, - headers, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Request failed' })); - throw new Error(error.message || 'Request failed'); - } - - return response.json(); -} +import { fetchWithAuth } from './client'; export interface BatchResult { success: number; diff --git a/apps/contacts/apps/web/src/lib/api/client.ts b/apps/contacts/apps/web/src/lib/api/client.ts new file mode 100644 index 000000000..750ed1668 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/api/client.ts @@ -0,0 +1,71 @@ +/** + * Centralized API client with authentication + */ + +import { authStore } from '$lib/stores/auth.svelte'; +import { API_BASE } from './config'; + +/** + * Make an authenticated API request + * @param url API endpoint (will be prefixed with API_BASE) + * @param options Fetch options + * @returns Parsed JSON response + */ +export async function fetchWithAuth( + url: string, + options: RequestInit = {} +): Promise { + const token = await authStore.getAccessToken(); + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE}${url}`, { + ...options, + headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || 'Request failed'); + } + + return response.json(); +} + +/** + * Make an authenticated API request without JSON content type + * Used for file uploads (FormData) + */ +export async function fetchWithAuthFormData( + url: string, + options: RequestInit = {} +): Promise { + const token = await authStore.getAccessToken(); + + const headers: HeadersInit = { + ...(options.headers || {}), + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE}${url}`, { + ...options, + headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || 'Request failed'); + } + + return response.json(); +} diff --git a/apps/contacts/apps/web/src/lib/api/config.ts b/apps/contacts/apps/web/src/lib/api/config.ts index 21c4a43ec..6316da671 100644 --- a/apps/contacts/apps/web/src/lib/api/config.ts +++ b/apps/contacts/apps/web/src/lib/api/config.ts @@ -1,7 +1,13 @@ -import { PUBLIC_BACKEND_URL } from '$env/static/public'; +import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public'; /** * API Configuration - * Uses environment variable PUBLIC_BACKEND_URL with fallback for development + * Uses environment variables with fallbacks for development */ export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`; + +/** + * Mana Core Auth URL + * Central authentication service URL + */ +export const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; diff --git a/apps/contacts/apps/web/src/lib/api/contacts.ts b/apps/contacts/apps/web/src/lib/api/contacts.ts index ee86bca7c..9ff6ef786 100644 --- a/apps/contacts/apps/web/src/lib/api/contacts.ts +++ b/apps/contacts/apps/web/src/lib/api/contacts.ts @@ -1,33 +1,9 @@ import { browser } from '$app/environment'; import { authStore } from '$lib/stores/auth.svelte'; -import { API_BASE } from './config'; +import { MANA_AUTH_URL } from './config'; +import { fetchWithAuth, fetchWithAuthFormData } from './client'; import { createTagsClient, type Tag } from '@manacore/shared-tags'; -async function fetchWithAuth(url: string, options: RequestInit = {}) { - const token = await authStore.getAccessToken(); - - const headers: HeadersInit = { - 'Content-Type': 'application/json', - ...(options.headers || {}), - }; - - if (token) { - (headers as Record)['Authorization'] = `Bearer ${token}`; - } - - const response = await fetch(`${API_BASE}${url}`, { - ...options, - headers, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Request failed' })); - throw new Error(error.message || 'Request failed'); - } - - return response.json(); -} - export interface Contact { id: string; userId: string; @@ -63,6 +39,8 @@ export interface Contact { signal?: string | null; discord?: string | null; bluesky?: string | null; + // Tags (populated by API) + tags?: Array<{ id: string; name: string; color: string | null }>; isFavorite: boolean; isArchived: boolean; organizationId?: string | null; @@ -104,9 +82,19 @@ export interface ContactFilters { offset?: number; } +// API Response types +interface ContactResponse { + contact: Contact; +} + +interface ContactListResponse { + contacts: Contact[]; + total: number; +} + // Contacts API export const contactsApi = { - async list(filters: ContactFilters = {}) { + async list(filters: ContactFilters = {}): Promise { const params = new URLSearchParams(); if (filters.search) params.set('search', filters.search); if (filters.isFavorite !== undefined) params.set('isFavorite', String(filters.isFavorite)); @@ -116,16 +104,16 @@ export const contactsApi = { if (filters.offset) params.set('offset', String(filters.offset)); const query = params.toString(); - return fetchWithAuth(`/contacts${query ? `?${query}` : ''}`); + return fetchWithAuth(`/contacts${query ? `?${query}` : ''}`); }, async get(id: string): Promise { - const response = await fetchWithAuth(`/contacts/${id}`); + const response = await fetchWithAuth(`/contacts/${id}`); return response.contact; }, async create(data: Partial): Promise { - const response = await fetchWithAuth('/contacts', { + const response = await fetchWithAuth('/contacts', { method: 'POST', body: JSON.stringify(data), }); @@ -133,7 +121,7 @@ export const contactsApi = { }, async update(id: string, data: Partial): Promise { - const response = await fetchWithAuth(`/contacts/${id}`, { + const response = await fetchWithAuth(`/contacts/${id}`, { method: 'PATCH', body: JSON.stringify(data), }); @@ -147,14 +135,14 @@ export const contactsApi = { }, async toggleFavorite(id: string): Promise { - const response = await fetchWithAuth(`/contacts/${id}/favorite`, { + const response = await fetchWithAuth(`/contacts/${id}/favorite`, { method: 'POST', }); return response.contact; }, async toggleArchive(id: string): Promise { - const response = await fetchWithAuth(`/contacts/${id}/archive`, { + const response = await fetchWithAuth(`/contacts/${id}/archive`, { method: 'POST', }); return response.contact; @@ -164,16 +152,6 @@ export const contactsApi = { // Tags API - Uses central Tags API from mana-core-auth // Contact-tag associations still use the Contacts backend -// Get auth URL dynamically at runtime -function getAuthUrl(): string { - if (browser && typeof window !== 'undefined') { - const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) - .__PUBLIC_MANA_CORE_AUTH_URL__; - return injectedUrl || 'http://localhost:3001'; - } - return 'http://localhost:3001'; -} - // Lazy-initialized tags client let _tagsClient: ReturnType | null = null; @@ -181,7 +159,7 @@ function getTagsClient() { if (!browser) return null; if (!_tagsClient) { _tagsClient = createTagsClient({ - authUrl: getAuthUrl(), + authUrl: MANA_AUTH_URL, getToken: async () => { const token = await authStore.getAccessToken(); return token || ''; @@ -226,19 +204,19 @@ export const tagsApi = { // Contact-tag associations still use Contacts backend async addToContact(tagId: string, contactId: string): Promise<{ success: boolean }> { - return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, { + return fetchWithAuth<{ success: boolean }>(`/tags/${tagId}/contacts/${contactId}`, { method: 'POST', }); }, async removeFromContact(tagId: string, contactId: string): Promise<{ success: boolean }> { - return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, { + return fetchWithAuth<{ success: boolean }>(`/tags/${tagId}/contacts/${contactId}`, { method: 'DELETE', }); }, async getForContact(contactId: string): Promise<{ tagIds: string[] }> { - return fetchWithAuth(`/tags/contact/${contactId}`); + return fetchWithAuth<{ tagIds: string[] }>(`/tags/contact/${contactId}`); }, // Create default tags via central Tags API @@ -250,44 +228,68 @@ export const tagsApi = { }, }; +// Notes API Response types +interface NotesListResponse { + notes: ContactNote[]; +} + +interface NoteResponse { + note: ContactNote; +} + // Notes API export const notesApi = { - async list(contactId: string) { - return fetchWithAuth(`/contacts/${contactId}/notes`); + async list(contactId: string): Promise { + return fetchWithAuth(`/contacts/${contactId}/notes`); }, - async create(contactId: string, data: { content: string; isPinned?: boolean }) { - return fetchWithAuth(`/contacts/${contactId}/notes`, { + async create( + contactId: string, + data: { content: string; isPinned?: boolean } + ): Promise { + return fetchWithAuth(`/contacts/${contactId}/notes`, { method: 'POST', body: JSON.stringify(data), }); }, - async update(noteId: string, data: { content?: string; isPinned?: boolean }) { - return fetchWithAuth(`/notes/${noteId}`, { + async update( + noteId: string, + data: { content?: string; isPinned?: boolean } + ): Promise { + return fetchWithAuth(`/notes/${noteId}`, { method: 'PATCH', body: JSON.stringify(data), }); }, - async delete(noteId: string) { - return fetchWithAuth(`/notes/${noteId}`, { + async delete(noteId: string): Promise { + await fetchWithAuth(`/notes/${noteId}`, { method: 'DELETE', }); }, - async togglePin(noteId: string) { - return fetchWithAuth(`/notes/${noteId}/pin`, { + async togglePin(noteId: string): Promise { + return fetchWithAuth(`/notes/${noteId}/pin`, { method: 'POST', }); }, }; +// Activities API Response types +interface ActivitiesListResponse { + activities: ContactActivity[]; +} + +interface ActivityResponse { + activity: ContactActivity; +} + // Activities API export const activitiesApi = { - async list(contactId: string, limit?: number) { + async list(contactId: string, limit?: number): Promise { const params = limit ? `?limit=${limit}` : ''; - return fetchWithAuth(`/contacts/${contactId}/activities${params}`); + return fetchWithAuth(`/contacts/${contactId}/activities${params}`); }, async create( @@ -297,8 +299,8 @@ export const activitiesApi = { description?: string; metadata?: Record; } - ) { - return fetchWithAuth(`/contacts/${contactId}/activities`, { + ): Promise { + return fetchWithAuth(`/contacts/${contactId}/activities`, { method: 'POST', body: JSON.stringify(data), }); @@ -308,25 +310,13 @@ export const activitiesApi = { // Photo API export const photoApi = { async upload(contactId: string, file: File): Promise<{ photoUrl: string }> { - const token = await authStore.getAccessToken(); - const formData = new FormData(); formData.append('photo', file); - const response = await fetch(`${API_BASE}/contacts/${contactId}/photo`, { + return fetchWithAuthFormData<{ photoUrl: string }>(`/contacts/${contactId}/photo`, { method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - }, body: formData, }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Upload failed' })); - throw new Error(error.message || 'Upload failed'); - } - - return response.json(); }, async delete(contactId: string): Promise { diff --git a/apps/contacts/apps/web/src/lib/components/AlphabetNavContextMenu.svelte b/apps/contacts/apps/web/src/lib/components/AlphabetNavContextMenu.svelte new file mode 100644 index 000000000..3c3e9f4d9 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/AlphabetNavContextMenu.svelte @@ -0,0 +1,86 @@ + + + diff --git a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte index e754a190b..386e2f0b2 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte @@ -5,6 +5,8 @@ import ContactNotes from './ContactNotes.svelte'; import ContactTasks from './ContactTasks.svelte'; import { ContactDetailSkeleton } from '$lib/components/skeletons'; + import SocialMediaFields from './forms/SocialMediaFields.svelte'; + import SocialMediaLinks from './SocialMediaLinks.svelte'; interface Props { contactId: string; @@ -50,7 +52,6 @@ let signal = $state(''); let discord = $state(''); let bluesky = $state(''); - let socialSectionOpen = $state(false); const initials = $derived(() => { if (!contact) return '?'; @@ -100,22 +101,6 @@ signal = contact.signal || ''; discord = contact.discord || ''; bluesky = contact.bluesky || ''; - // Auto-open social section if any social field has data - socialSectionOpen = !!( - contact.linkedin || - contact.twitter || - contact.facebook || - contact.instagram || - contact.xing || - contact.github || - contact.youtube || - contact.tiktok || - contact.telegram || - contact.whatsapp || - contact.signal || - contact.discord || - contact.bluesky - ); } function getDisplayName() { @@ -538,213 +523,22 @@ - -
- - {#if socialSectionOpen} - - {/if} -
+ +
@@ -1108,176 +902,7 @@ {/if} - {#if contact.linkedin || contact.twitter || contact.facebook || contact.instagram || contact.xing || contact.github || contact.youtube || contact.tiktok || contact.telegram || contact.whatsapp || contact.signal || contact.discord || contact.bluesky} -
-
-
- - - -
-

Social Media

-
- -
- {/if} + @@ -1981,122 +1606,5 @@ .quick-actions { gap: 1rem; } - - .social-grid { - grid-template-columns: 1fr; - } - } - - /* Social Media Section */ - .section-header-toggle { - width: 100%; - background: none; - border: none; - cursor: pointer; - border-bottom: 1px solid hsl(var(--color-border) / 0.5); - margin-bottom: 0; - } - - .section-header-toggle:hover { - background: hsl(var(--color-surface-hover) / 0.3); - margin: 0 -1rem; - padding: 0 1rem 0.625rem; - width: calc(100% + 2rem); - border-radius: 0.5rem 0.5rem 0 0; - } - - .chevron-icon { - width: 1rem; - height: 1rem; - margin-left: auto; - color: hsl(var(--color-muted-foreground)); - transition: transform 0.2s ease; - } - - .chevron-open { - transform: rotate(180deg); - } - - .social-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.75rem; - padding-top: 0.75rem; - } - - .social-label { - display: flex; - align-items: center; - gap: 0.5rem; - } - - .social-icon-label { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - border-radius: 0.25rem; - background: hsl(var(--color-primary) / 0.1); - color: hsl(var(--color-primary)); - font-size: 0.625rem; - font-weight: 700; - text-transform: lowercase; - } - - /* Social Links in View Mode */ - .social-links-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.5rem; - } - - .social-link { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.625rem; - background: hsl(var(--color-muted) / 0.5); - border-radius: 0.5rem; - text-decoration: none; - color: inherit; - transition: all 0.2s ease; - } - - .social-link:hover:not(.social-link-static) { - background: hsl(var(--color-primary) / 0.1); - color: hsl(var(--color-primary)); - } - - .social-link-static { - cursor: default; - } - - .social-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - border-radius: 0.375rem; - background: hsl(var(--color-primary) / 0.15); - color: hsl(var(--color-primary)); - font-size: 0.625rem; - font-weight: 700; - flex-shrink: 0; - } - - .social-link-text { - font-size: 0.8125rem; - font-weight: 500; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - @media (max-width: 480px) { - .social-links-grid { - grid-template-columns: 1fr; - } } diff --git a/apps/contacts/apps/web/src/lib/components/ContactList.svelte b/apps/contacts/apps/web/src/lib/components/ContactList.svelte index 63ff53273..48a20a6e8 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactList.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactList.svelte @@ -5,12 +5,17 @@ import { viewModeStore } from '$lib/stores/view-mode.svelte'; import { contactsFilterStore } from '$lib/stores/filter.svelte'; import { goto } from '$app/navigation'; - import ContactListView from '$lib/components/views/ContactListView.svelte'; import ContactGridView from '$lib/components/views/ContactGridView.svelte'; import ContactAlphabetView from '$lib/components/views/ContactAlphabetView.svelte'; - import { ContactListSkeleton, ContactGridSkeleton } from '$lib/components/skeletons'; + import ContactNetworkView from '$lib/components/views/ContactNetworkView.svelte'; + import { + ContactListSkeleton, + ContactGridSkeleton, + NetworkGraphSkeleton, + } from '$lib/components/skeletons'; import { batchApi } from '$lib/api/batch'; import { toasts } from '$lib/stores/toast'; + import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte'; // Infinite scroll let intersectionObserver: IntersectionObserver | null = null; @@ -288,7 +293,7 @@
-

{$_('contacts.title')}

+

{$_('contacts.title')}

{#if selectionMode} @@ -369,7 +374,9 @@ {#if contactsStore.loading} - {#if viewModeStore.mode === 'grid'} + {#if viewModeStore.mode === 'network'} + + {:else if viewModeStore.mode === 'grid'} {:else} @@ -380,13 +387,15 @@
👤

{$_('contacts.noContacts')}

{$_('contacts.addFirst')}

- +
{:else} - {#if viewModeStore.mode === 'grid'} + {#if viewModeStore.mode === 'network'} + + {:else if viewModeStore.mode === 'grid'} - {:else if viewModeStore.mode === 'alphabet'} + {:else} - {:else} - {/if} - - {#if contactsStore.hasMore} + + {#if viewModeStore.mode !== 'network' && contactsStore.hasMore}
{#if contactsStore.loadingMore}
@@ -428,11 +428,13 @@
{/if} - -

- {contactsStore.contacts.length} / {contactsStore.total} - {contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')} -

+ + {#if viewModeStore.mode !== 'network'} +

+ {contactsStore.contacts.length} / {contactsStore.total} + {contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')} +

+ {/if} {/if}
diff --git a/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte b/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte index 454d92623..1cbcb4eaa 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactsToolbarContent.svelte @@ -1,10 +1,13 @@
- - contactsFilterStore.setSelectedTagId(id)} - contactFilter={contactsFilterStore.contactFilter} - onContactFilterChange={(f) => contactsFilterStore.setContactFilter(f)} - birthdayFilter={contactsFilterStore.birthdayFilter} - onBirthdayFilterChange={(f) => contactsFilterStore.setBirthdayFilter(f)} - selectedCompany={contactsFilterStore.selectedCompany} - onCompanyChange={(c) => contactsFilterStore.setSelectedCompany(c)} - embedded={true} - /> + {#if isNetworkMode} + -
+ +
+ + +
- - +
-
+ +
+ + + + +
- -
- + {/if} + + +
+ {networkStore.nodes.length} {$_('contacts.contactsPlural')} + + {networkStore.links.length} {$_('network.connections')} +
+ {:else} + + + +
+ + contactsFilterStore.setSelectedTagId(typeof v === 'string' ? v : null)} + placeholder={$_('filters.allTags')} + embedded={true} + direction="up" + /> + + + + contactsFilterStore.setContactFilter( + (typeof v === 'string' ? v : 'all') as ContactFilter + )} + placeholder={$_('filters.contact.all')} + embedded={true} + direction="up" + /> + + + + contactsFilterStore.setBirthdayFilter( + (typeof v === 'string' ? v : 'all') as BirthdayFilter + )} + placeholder={$_('filters.birthday.all')} + embedded={true} + direction="up" + /> + + + {#if companyOptions.length > 0} + contactsFilterStore.setSelectedCompany(typeof v === 'string' ? v : null)} + placeholder={$_('filters.allCompanies')} + embedded={true} + direction="up" /> - - - - -
+ {/if} + + + {#if activeFilterCount > 0} + + {/if} +
+ +
+ + + + {/if}
diff --git a/apps/contacts/apps/web/src/lib/components/NewContactModal.svelte b/apps/contacts/apps/web/src/lib/components/NewContactModal.svelte index 73090fb9d..4bad53fab 100644 --- a/apps/contacts/apps/web/src/lib/components/NewContactModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/NewContactModal.svelte @@ -3,6 +3,7 @@ import { contactsApi, photoApi } from '$lib/api/contacts'; import { contactsStore } from '$lib/stores/contacts.svelte'; import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte'; + import SocialMediaFields from './forms/SocialMediaFields.svelte'; interface Props { onClose: () => void; @@ -48,7 +49,6 @@ let signal = $state(''); let discord = $state(''); let bluesky = $state(''); - let socialSectionOpen = $state(false); const initials = $derived(() => { const f = firstName?.[0] || ''; @@ -533,213 +533,22 @@ > - -
- - {#if socialSectionOpen} - - {/if} -
+ +
@@ -1195,66 +1004,5 @@ .actions { flex-direction: column-reverse; } - - .social-grid { - grid-template-columns: 1fr; - } - } - - /* Social Media Section */ - .section-header-toggle { - width: 100%; - background: none; - border: none; - cursor: pointer; - border-bottom: 1px solid hsl(var(--color-border) / 0.5); - margin-bottom: 0; - } - - .section-header-toggle:hover { - background: hsl(var(--color-surface-hover) / 0.3); - margin: 0 -1rem; - padding: 0 1rem 0.625rem; - width: calc(100% + 2rem); - border-radius: 0.5rem 0.5rem 0 0; - } - - .chevron-icon { - width: 1rem; - height: 1rem; - margin-left: auto; - color: hsl(var(--color-muted-foreground)); - transition: transform 0.2s ease; - } - - .chevron-open { - transform: rotate(180deg); - } - - .social-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.75rem; - padding-top: 0.75rem; - } - - .social-label { - display: flex; - align-items: center; - gap: 0.5rem; - } - - .social-icon-label { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - border-radius: 0.25rem; - background: hsl(var(--color-primary) / 0.1); - color: hsl(var(--color-primary)); - font-size: 0.625rem; - font-weight: 700; - text-transform: lowercase; } diff --git a/apps/contacts/apps/web/src/lib/components/SearchModal.svelte b/apps/contacts/apps/web/src/lib/components/SearchModal.svelte index 38eddb294..cea269e12 100644 --- a/apps/contacts/apps/web/src/lib/components/SearchModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/SearchModal.svelte @@ -1,6 +1,7 @@ + +{#if hasAny} +
+
+
+ + + +
+

Social Media

+
+ +
+{/if} + + diff --git a/apps/contacts/apps/web/src/lib/components/forms/SocialMediaFields.svelte b/apps/contacts/apps/web/src/lib/components/forms/SocialMediaFields.svelte new file mode 100644 index 000000000..0cda4d897 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/forms/SocialMediaFields.svelte @@ -0,0 +1,369 @@ + + +
+ + {#if isOpen} + + {/if} +
+ + diff --git a/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte b/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte index af1ff0d97..2fd293032 100644 --- a/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte +++ b/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte @@ -347,15 +347,15 @@ {node.name} - - {#if node.company} + + {#if node.subtitle} - {node.company} + {node.subtitle} {/if} diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte index 362727151..004482fe3 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte +++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte @@ -5,6 +5,7 @@ */ import ContactCardSkeleton from './ContactCardSkeleton.svelte'; + import { calculateFadeOpacity } from './utils'; interface Props { /** Number of skeleton cards to show */ @@ -16,17 +17,11 @@ } let { count = 8, fadeEffect = true, minOpacity = 0.4 }: Props = $props(); - - function calculateOpacity(index: number): number { - if (!fadeEffect) return 1; - const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); - return Math.max(minOpacity, 1 - index * fadeStep); - }
{#each Array(count) as _, i} - + {/each}
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte index 3bb93cb5a..3e81b4146 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte +++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte @@ -5,6 +5,7 @@ */ import ContactRowSkeleton from './ContactRowSkeleton.svelte'; + import { calculateFadeOpacity } from './utils'; interface Props { /** Number of skeleton rows to show */ @@ -16,16 +17,10 @@ } let { count = 8, fadeEffect = true, minOpacity = 0.3 }: Props = $props(); - - function calculateOpacity(index: number): number { - if (!fadeEffect) return 1; - const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); - return Math.max(minOpacity, 1 - index * fadeStep); - }
{#each Array(count) as _, i} - + {/each}
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte index 7e618b617..0295a9191 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte +++ b/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte @@ -6,6 +6,7 @@ import { SkeletonBox } from '@manacore/shared-ui'; import DuplicateGroupSkeleton from './DuplicateGroupSkeleton.svelte'; + import { calculateFadeOpacity } from './utils'; interface Props { /** Number of duplicate groups to show */ @@ -17,12 +18,6 @@ } let { count = 3, fadeEffect = true, minOpacity = 0.4 }: Props = $props(); - - function calculateOpacity(index: number): number { - if (!fadeEffect) return 1; - const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); - return Math.max(minOpacity, 1 - index * fadeStep); - }
@@ -39,7 +34,10 @@
{#each Array(count) as _, i} - + {/each}
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte index f1c72956f..171fc0c14 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte +++ b/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte @@ -4,6 +4,7 @@ */ import TagCardSkeleton from './TagCardSkeleton.svelte'; + import { calculateFadeOpacity } from './utils'; interface Props { /** Number of skeleton cards to show */ @@ -15,17 +16,11 @@ } let { count = 6, fadeEffect = true, minOpacity = 0.4 }: Props = $props(); - - function calculateOpacity(index: number): number { - if (!fadeEffect) return 1; - const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); - return Math.max(minOpacity, 1 - index * fadeStep); - }
{#each Array(count) as _, i} - + {/each}
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/index.ts b/apps/contacts/apps/web/src/lib/components/skeletons/index.ts index 82732122c..c67ab3f59 100644 --- a/apps/contacts/apps/web/src/lib/components/skeletons/index.ts +++ b/apps/contacts/apps/web/src/lib/components/skeletons/index.ts @@ -5,6 +5,9 @@ * Built on top of @manacore/shared-ui skeleton primitives. */ +// Utilities +export { calculateFadeOpacity } from './utils'; + // Contact List/Grid Skeletons export { default as ContactRowSkeleton } from './ContactRowSkeleton.svelte'; export { default as ContactListSkeleton } from './ContactListSkeleton.svelte'; @@ -15,10 +18,6 @@ export { default as ContactGridSkeleton } from './ContactGridSkeleton.svelte'; export { default as TagCardSkeleton } from './TagCardSkeleton.svelte'; export { default as TagGridSkeleton } from './TagGridSkeleton.svelte'; -// Favorite Skeletons -export { default as FavoriteCardSkeleton } from './FavoriteCardSkeleton.svelte'; -export { default as FavoriteGridSkeleton } from './FavoriteGridSkeleton.svelte'; - // Duplicate Skeletons export { default as DuplicateGroupSkeleton } from './DuplicateGroupSkeleton.svelte'; export { default as DuplicateListSkeleton } from './DuplicateListSkeleton.svelte'; diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/utils.ts b/apps/contacts/apps/web/src/lib/components/skeletons/utils.ts new file mode 100644 index 000000000..ccd94edbb --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/skeletons/utils.ts @@ -0,0 +1,19 @@ +/** + * Skeleton utility functions + */ + +/** + * Calculate opacity for cascading fade effect in skeleton lists + * @param index Current item index + * @param count Total number of items + * @param minOpacity Minimum opacity (default: 0.3) + * @returns Opacity value between minOpacity and 1 + */ +export function calculateFadeOpacity( + index: number, + count: number, + minOpacity: number = 0.3 +): number { + const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1); + return Math.max(minOpacity, 1 - index * fadeStep); +} diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte index 244b244d5..8ac723aa5 100644 --- a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte +++ b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte @@ -5,6 +5,8 @@ import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte'; import { isSidebarMode } from '$lib/stores/navigation'; import { contactsFilterStore } from '$lib/stores/filter.svelte'; + import { contactsSettings } from '$lib/stores/settings.svelte'; + import AlphabetNavContextMenu from '$lib/components/AlphabetNavContextMenu.svelte'; interface Props { contacts: Contact[]; @@ -36,12 +38,27 @@ contactsFilterStore.toggleAlphabetNav(); } + // Context menu for alphabet nav + let alphabetContextMenu: AlphabetNavContextMenu; + + function handleAlphabetContextMenu(e: MouseEvent) { + e.preventDefault(); + alphabetContextMenu?.show(e.clientX, e.clientY); + } + function handleCheckboxClick(e: MouseEvent, id: string) { e.stopPropagation(); onToggleSelection?.(id); } - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + // Alphabet with optional reverse order + let alphabet = $derived.by(() => { + let letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + if (contactsSettings.alphabetNavReverseOrder) { + letters = letters.reverse(); + } + return letters; + }); function getInitials(contact: Contact) { const first = contact.firstName?.[0] || ''; @@ -267,13 +284,15 @@ class:sidebar-mode={$isSidebarMode} class:toolbar-expanded={isToolbarExpanded} > + + {/if} + {/each} + {#if contactsSettings.alphabetNavShowHash && availableLetters.includes('#')} - {/each} - {#if availableLetters.includes('#')} - {/if}
{/if} + + diff --git a/apps/contacts/apps/web/src/lib/config/social-media.ts b/apps/contacts/apps/web/src/lib/config/social-media.ts new file mode 100644 index 000000000..784139d35 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/config/social-media.ts @@ -0,0 +1,168 @@ +/** + * Social Media Platform Configuration + * Centralized config for all social media platforms used in the contacts app + */ + +export interface SocialPlatform { + /** Unique identifier matching Contact field name */ + id: string; + /** Display name */ + name: string; + /** Short badge label */ + badge: string; + /** Input type for form fields */ + inputType: 'url' | 'text' | 'tel'; + /** Placeholder text for form input */ + placeholder: string; + /** Whether this platform has a clickable link */ + hasLink: boolean; + /** Function to build the full URL from a value */ + buildUrl?: (value: string) => string; +} + +/** + * All supported social media platforms + */ +export const SOCIAL_PLATFORMS: SocialPlatform[] = [ + { + id: 'linkedin', + name: 'LinkedIn', + badge: 'in', + inputType: 'url', + placeholder: 'https://linkedin.com/in/...', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://linkedin.com/in/${v}`), + }, + { + id: 'twitter', + name: 'Twitter / X', + badge: 'X', + inputType: 'text', + placeholder: '@username', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://x.com/${v.replace('@', '')}`), + }, + { + id: 'facebook', + name: 'Facebook', + badge: 'f', + inputType: 'url', + placeholder: 'https://facebook.com/...', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://facebook.com/${v}`), + }, + { + id: 'instagram', + name: 'Instagram', + badge: 'ig', + inputType: 'text', + placeholder: '@username', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://instagram.com/${v.replace('@', '')}`), + }, + { + id: 'xing', + name: 'Xing', + badge: 'xi', + inputType: 'url', + placeholder: 'https://xing.com/profile/...', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://xing.com/profile/${v}`), + }, + { + id: 'github', + name: 'GitHub', + badge: 'gh', + inputType: 'text', + placeholder: 'username', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://github.com/${v}`), + }, + { + id: 'youtube', + name: 'YouTube', + badge: 'yt', + inputType: 'url', + placeholder: 'https://youtube.com/@...', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://youtube.com/@${v}`), + }, + { + id: 'tiktok', + name: 'TikTok', + badge: 'tt', + inputType: 'text', + placeholder: '@username', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://tiktok.com/@${v.replace('@', '')}`), + }, + { + id: 'telegram', + name: 'Telegram', + badge: 'tg', + inputType: 'text', + placeholder: '@username', + hasLink: true, + buildUrl: (v) => `https://t.me/${v.replace('@', '')}`, + }, + { + id: 'whatsapp', + name: 'WhatsApp', + badge: 'wa', + inputType: 'tel', + placeholder: '+49...', + hasLink: true, + buildUrl: (v) => `https://wa.me/${v.replace(/[^0-9]/g, '')}`, + }, + { + id: 'signal', + name: 'Signal', + badge: 'sg', + inputType: 'tel', + placeholder: '+49...', + hasLink: false, + }, + { + id: 'discord', + name: 'Discord', + badge: 'dc', + inputType: 'text', + placeholder: 'username#1234', + hasLink: false, + }, + { + id: 'bluesky', + name: 'Bluesky', + badge: 'bs', + inputType: 'text', + placeholder: '@handle.bsky.social', + hasLink: true, + buildUrl: (v) => (v.startsWith('http') ? v : `https://bsky.app/profile/${v.replace('@', '')}`), + }, +]; + +/** + * Get platform config by ID + */ +export function getPlatform(id: string): SocialPlatform | undefined { + return SOCIAL_PLATFORMS.find((p) => p.id === id); +} + +/** + * Check if a contact has any social media data + */ +export function hasSocialMedia(contact: Record): boolean { + return SOCIAL_PLATFORMS.some((p) => !!contact[p.id]); +} + +/** + * Get all social media entries for a contact + */ +export function getSocialMediaEntries( + contact: Record +): Array<{ platform: SocialPlatform; value: string }> { + return SOCIAL_PLATFORMS.filter((p) => !!contact[p.id]).map((platform) => ({ + platform, + value: contact[platform.id] as string, + })); +} diff --git a/apps/contacts/apps/web/src/lib/services/feedback.ts b/apps/contacts/apps/web/src/lib/services/feedback.ts index 95dc1656d..fd578e9d9 100644 --- a/apps/contacts/apps/web/src/lib/services/feedback.ts +++ b/apps/contacts/apps/web/src/lib/services/feedback.ts @@ -4,8 +4,7 @@ import { createFeedbackService } from '@manacore/shared-feedback-service'; import { authStore } from '$lib/stores/auth.svelte'; - -const MANA_AUTH_URL = 'http://localhost:3001'; +import { MANA_AUTH_URL } from '$lib/api/config'; export const feedbackService = createFeedbackService({ apiUrl: MANA_AUTH_URL, diff --git a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts index 3122bf4f1..d92832e5d 100644 --- a/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/auth.svelte.ts @@ -6,10 +6,7 @@ import { browser } from '$app/environment'; import { initializeWebAuth } from '@manacore/shared-auth'; import type { UserData } from '@manacore/shared-auth'; - -// Initialize Mana Core Auth only on the client side -// TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env when available -const MANA_AUTH_URL = 'http://localhost:3001'; +import { MANA_AUTH_URL } from '$lib/api/config'; // Lazy initialization to avoid SSR issues with localStorage let _authService: ReturnType['authService'] | null = null; diff --git a/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts b/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts index a715eb0ba..426dec25b 100644 --- a/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/filter.svelte.ts @@ -62,8 +62,18 @@ function saveState(state: ContactsFilterState) { // Reactive state let state = $state(DEFAULT_STATE); +// Generic update helper +function update( + key: K, + value: ContactsFilterState[K], + persist = true +) { + state = { ...state, [key]: value }; + if (persist) saveState(state); +} + export const contactsFilterStore = { - // Getters + // Getters - Required for Svelte 5 reactivity get sortField() { return state.sortField; }, @@ -90,57 +100,23 @@ export const contactsFilterStore = { }, // Setters - setSortField(value: SortField) { - state = { ...state, sortField: value }; - saveState(state); - }, - - setContactFilter(value: ContactFilter) { - state = { ...state, contactFilter: value }; - saveState(state); - }, - - setBirthdayFilter(value: BirthdayFilter) { - state = { ...state, birthdayFilter: value }; - saveState(state); - }, - - setSelectedTagId(value: string | null) { - state = { ...state, selectedTagId: value }; - saveState(state); - }, - - setSelectedCompany(value: string | null) { - state = { ...state, selectedCompany: value }; - saveState(state); - }, - - setToolbarCollapsed(value: boolean) { - state = { ...state, isToolbarCollapsed: value }; - saveState(state); - }, + setSortField: (value: SortField) => update('sortField', value), + setContactFilter: (value: ContactFilter) => update('contactFilter', value), + setBirthdayFilter: (value: BirthdayFilter) => update('birthdayFilter', value), + setSelectedTagId: (value: string | null) => update('selectedTagId', value), + setSelectedCompany: (value: string | null) => update('selectedCompany', value), + setToolbarCollapsed: (value: boolean) => update('isToolbarCollapsed', value), + setAlphabetNavCollapsed: (value: boolean) => update('isAlphabetNavCollapsed', value), + setSearchQuery: (value: string) => update('searchQuery', value, false), toggleToolbar() { - state = { ...state, isToolbarCollapsed: !state.isToolbarCollapsed }; - saveState(state); - }, - - setAlphabetNavCollapsed(value: boolean) { - state = { ...state, isAlphabetNavCollapsed: value }; - saveState(state); + update('isToolbarCollapsed', !state.isToolbarCollapsed); }, toggleAlphabetNav() { - state = { ...state, isAlphabetNavCollapsed: !state.isAlphabetNavCollapsed }; - saveState(state); + update('isAlphabetNavCollapsed', !state.isAlphabetNavCollapsed); }, - setSearchQuery(value: string) { - state = { ...state, searchQuery: value }; - // Don't persist search query to localStorage - }, - - // Reset filters (but not toolbar state) resetFilters() { state = { ...state, @@ -153,7 +129,6 @@ export const contactsFilterStore = { saveState(state); }, - // Initialize from localStorage initialize() { if (!browser) return; state = loadState(); diff --git a/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts index 7480186ce..c5c89622f 100644 --- a/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts @@ -8,7 +8,7 @@ import { browser } from '$app/environment'; // Settings types export type ContactSortBy = 'name' | 'company' | 'created' | 'updated'; export type ContactSortOrder = 'asc' | 'desc'; -export type ContactView = 'grid' | 'alphabet'; +export type ContactView = 'grid' | 'alphabet' | 'network'; export type DateFormat = 'dd.MM.yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'; export interface ContactsAppSettings { @@ -55,6 +55,16 @@ export interface ContactsAppSettings { privacyMode: boolean; /** Require confirmation before sharing contact */ confirmBeforeSharing: boolean; + + // Alphabet Navigation Settings + /** Hide letters that have no contacts */ + alphabetNavHideInactive: boolean; + /** Use compact/smaller alphabet buttons */ + alphabetNavCompact: boolean; + /** Reverse letter order (Z-A instead of A-Z) */ + alphabetNavReverseOrder: boolean; + /** Show # symbol for non-letter names */ + alphabetNavShowHash: boolean; } const DEFAULT_SETTINGS: ContactsAppSettings = { @@ -84,6 +94,12 @@ const DEFAULT_SETTINGS: ContactsAppSettings = { // Privacy privacyMode: false, confirmBeforeSharing: true, + + // Alphabet Navigation + alphabetNavHideInactive: false, + alphabetNavCompact: false, + alphabetNavReverseOrder: false, + alphabetNavShowHash: true, }; const STORAGE_KEY = 'contacts-settings'; @@ -187,6 +203,20 @@ export const contactsSettings = { return settings.confirmBeforeSharing; }, + // Alphabet Navigation + get alphabetNavHideInactive() { + return settings.alphabetNavHideInactive; + }, + get alphabetNavCompact() { + return settings.alphabetNavCompact; + }, + get alphabetNavReverseOrder() { + return settings.alphabetNavReverseOrder; + }, + get alphabetNavShowHash() { + return settings.alphabetNavShowHash; + }, + /** * Initialize settings from localStorage */ diff --git a/apps/contacts/apps/web/src/lib/stores/user-settings.svelte.ts b/apps/contacts/apps/web/src/lib/stores/user-settings.svelte.ts index 70c7b99ae..e70961ed6 100644 --- a/apps/contacts/apps/web/src/lib/stores/user-settings.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/user-settings.svelte.ts @@ -9,8 +9,7 @@ import { createUserSettingsStore } from '@manacore/shared-theme'; import { authStore } from './auth.svelte'; - -const MANA_AUTH_URL = 'http://localhost:3001'; +import { MANA_AUTH_URL } from '$lib/api/config'; export const userSettings = createUserSettingsStore({ appId: 'contacts', diff --git a/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts b/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts index 74bd7a442..258e1d63c 100644 --- a/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts +++ b/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts @@ -10,13 +10,20 @@ export type ViewMode = ContactView; const STORAGE_KEY = 'contacts-view-mode'; +// Valid view modes +const VALID_MODES: ViewMode[] = ['grid', 'alphabet', 'network']; + +function isValidMode(mode: string | null): mode is ViewMode { + return mode !== null && VALID_MODES.includes(mode as ViewMode); +} + // Get initial mode: current session preference > settings default > 'alphabet' function getInitialMode(): ViewMode { if (!browser) return 'alphabet'; // First check if there's a session-specific preference const sessionMode = sessionStorage.getItem(STORAGE_KEY); - if (sessionMode === 'grid' || sessionMode === 'alphabet') { + if (isValidMode(sessionMode)) { return sessionMode; } @@ -57,7 +64,7 @@ export const viewModeStore = { // Check if there's a session preference const sessionMode = sessionStorage.getItem(STORAGE_KEY); - if (sessionMode === 'grid' || sessionMode === 'alphabet') { + if (isValidMode(sessionMode)) { mode = sessionMode; } else { // Use default from settings diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index a7bf002e3..07e5d6ae4 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -13,7 +13,6 @@ PillNavItem, PillDropdownItem, QuickInputItem, - QuickAction, CreatePreview, } from '@manacore/shared-ui'; import { theme } from '$lib/stores/theme'; @@ -47,8 +46,6 @@ formatParsedContactPreview, } from '$lib/utils/contact-parser'; import ContactsToolbar from '$lib/components/ContactsToolbar.svelte'; - import NetworkToolbar from '$lib/components/NetworkToolbar.svelte'; - import { networkStore } from '$lib/stores/network.svelte'; // Tags state for Quick-Create let availableTags = $state<{ id: string; name: string }[]>([]); @@ -77,23 +74,16 @@ // Show toolbar only on main contacts page const showContactsToolbar = $derived($page.url.pathname === '/' && !isSidebarMode); - // Show network toolbar only on network page - const showNetworkToolbar = $derived($page.url.pathname === '/network' && !isSidebarMode); - - // Check if any toolbar is expanded - const isAnyToolbarExpanded = $derived( - (showContactsToolbar && !contactsFilterStore.isToolbarCollapsed) || - (showNetworkToolbar && !networkStore.isToolbarCollapsed) + // Check if toolbar is expanded + const isToolbarExpanded = $derived( + showContactsToolbar && !contactsFilterStore.isToolbarCollapsed ); // Dynamic bottom offset based on toolbar state const inputBarBottomOffset = $derived( - isSidebarMode ? '0px' : isAnyToolbarExpanded ? '140px' : '70px' + isSidebarMode ? '0px' : isToolbarExpanded ? '140px' : '70px' ); - // Show FAB when any toolbar is active - const showToolbarFab = $derived(showContactsToolbar || showNetworkToolbar); - // Use theme store's isDark directly let isDark = $derived(theme.isDark); @@ -147,9 +137,7 @@ const baseNavItems: PillNavItem[] = [ { href: '/', label: 'Kontakte', icon: 'users' }, { href: '/tags', label: 'Tags', icon: 'tag' }, - { href: '/favorites', label: 'Favoriten', icon: 'heart' }, { href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' }, - { href: '/network', label: 'Netzwerk', icon: 'share-2' }, { href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, { href: '/help', label: 'Hilfe', icon: 'help-circle' }, @@ -270,13 +258,6 @@ }); } - // QuickInputBar quick actions - const quickActions: QuickAction[] = [ - { id: 'favorites', label: 'Favoriten', icon: 'heart', href: '/favorites' }, - { id: 'tags', label: 'Tags', icon: 'tag', href: '/tags' }, - { id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' }, - ]; - onMount(async () => { // Redirect to login if not authenticated if (!authStore.isAuthenticated) { @@ -386,7 +367,6 @@ onSearch={handleSearch} onSelect={handleSelect} onSearchChange={(query) => contactsFilterStore.setSearchQuery(query)} - {quickActions} placeholder="Neuer Kontakt oder suchen..." emptyText="Keine Kontakte gefunden" searchingText="Suche..." @@ -394,21 +374,15 @@ onParseCreate={handleParseCreate} createText="Erstellen" appIcon="contacts" - primaryColor="#3b82f6" autoFocus={true} bottomOffset={inputBarBottomOffset} - hasFabRight={showToolbarFab} + hasFabRight={showContactsToolbar} /> {#if showContactsToolbar} {/if} - - - {#if showNetworkToolbar} - - {/if} @@ -444,9 +418,7 @@ } .content-wrapper { - max-width: 900px; - margin-left: auto; - margin-right: auto; + /* No max-width - let individual views control their own width */ padding: 1rem; } @@ -461,4 +433,23 @@ padding: 2rem; } } + + /* Adjust InputBar when toolbar elements (view-mode-pill + FAB) are visible */ + /* Pill left edge is at: 50% - 238px from right edge of viewport */ + /* This means from center, there's 238px to the pill's left edge */ + /* For a centered InputBar with max-width W, right edge is at: center + W/2 */ + /* We need: center + W/2 < center + 238 - 12px gap, so W/2 < 226, W < 452px */ + :global(.quick-input-bar.has-fab-right .input-container) { + max-width: 450px; + } + + /* On smaller screens (<900px), the FAB + pill move to right: 1rem position */ + /* So we need fixed padding instead */ + @media (max-width: 900px) { + :global(.quick-input-bar.has-fab-right .input-container) { + max-width: calc(100% - 200px); /* ~120px pill + 8px + 54px FAB + 18px gap */ + margin-left: 0; + margin-right: auto; + } + } diff --git a/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte index 33795c905..b5702ea51 100644 --- a/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte @@ -25,9 +25,9 @@ // Options for selects const viewOptions = [ - { value: 'list', label: 'Liste' }, { value: 'grid', label: 'Kacheln' }, { value: 'alphabet', label: 'Alphabetisch' }, + { value: 'network', label: 'Netzwerk' }, ]; const sortByOptions = [ @@ -63,7 +63,6 @@ const startPageLabels: Record = { 'nav.contacts': 'Kontakte', 'nav.groups': 'Gruppen', - 'nav.favorites': 'Favoriten', }; function translateLabel(key: string): string { diff --git a/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte index f288dbd02..ae5532654 100644 --- a/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/statistics/+page.svelte @@ -1,8 +1,8 @@ - {translations.title} | Contacts + {translations.titleForm} | Contacts