diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/AppLoadingSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/AppLoadingSkeleton.svelte
new file mode 100644
index 000000000..a0fb33238
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/AppLoadingSkeleton.svelte
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#each Array(5) as _, i}
+
+ {/each}
+
+
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactCardSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactCardSkeleton.svelte
new file mode 100644
index 000000000..2d3430e1c
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactCardSkeleton.svelte
@@ -0,0 +1,70 @@
+
+
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactDetailSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactDetailSkeleton.svelte
new file mode 100644
index 000000000..96318820b
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactDetailSkeleton.svelte
@@ -0,0 +1,171 @@
+
+
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte
new file mode 100644
index 000000000..362727151
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactGridSkeleton.svelte
@@ -0,0 +1,57 @@
+
+
+
+ {#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
new file mode 100644
index 000000000..3bb93cb5a
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactListSkeleton.svelte
@@ -0,0 +1,31 @@
+
+
+
+ {#each Array(count) as _, i}
+
+ {/each}
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactNotesSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactNotesSkeleton.svelte
new file mode 100644
index 000000000..6ebee670b
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactNotesSkeleton.svelte
@@ -0,0 +1,40 @@
+
+
+
+ {#each Array(3) as _, i}
+
+ {/each}
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ContactRowSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ContactRowSkeleton.svelte
new file mode 100644
index 000000000..58fb5d938
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/ContactRowSkeleton.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateGroupSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateGroupSkeleton.svelte
new file mode 100644
index 000000000..bac423136
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateGroupSkeleton.svelte
@@ -0,0 +1,89 @@
+
+
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte
new file mode 100644
index 000000000..7e618b617
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/DuplicateListSkeleton.svelte
@@ -0,0 +1,69 @@
+
+
+
+
+
+ {#each Array(3) as _}
+
+
+
+
+ {/each}
+
+
+
+
+ {#each Array(count) as _, i}
+
+ {/each}
+
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteCardSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteCardSkeleton.svelte
new file mode 100644
index 000000000..5d5d3214b
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteCardSkeleton.svelte
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteGridSkeleton.svelte
new file mode 100644
index 000000000..452e4c079
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/FavoriteGridSkeleton.svelte
@@ -0,0 +1,56 @@
+
+
+
+ {#each Array(count) as _, i}
+
+ {/each}
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/GoogleImportSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/GoogleImportSkeleton.svelte
new file mode 100644
index 000000000..35305687c
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/GoogleImportSkeleton.svelte
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+ {#each Array(6) as _, i}
+
+ {/each}
+
+
+
+
+
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/ImportPreviewSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/ImportPreviewSkeleton.svelte
new file mode 100644
index 000000000..d62e68f64
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/ImportPreviewSkeleton.svelte
@@ -0,0 +1,118 @@
+
+
+
+
+
+ {#each Array(3) as _}
+
+
+
+
+ {/each}
+
+
+
+
+
+
+
+ {#each Array(5) as _, i}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/NetworkGraphSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/NetworkGraphSkeleton.svelte
new file mode 100644
index 000000000..c1e3ae27a
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/NetworkGraphSkeleton.svelte
@@ -0,0 +1,115 @@
+
+
+
+
+
+ {#each Array(8) as _, i}
+
+
+
+ {/each}
+
+
+
+
+
+
+
+
+
Netzwerk wird geladen...
+
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/TagCardSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/TagCardSkeleton.svelte
new file mode 100644
index 000000000..09ff2e540
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/TagCardSkeleton.svelte
@@ -0,0 +1,36 @@
+
+
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte b/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte
new file mode 100644
index 000000000..f1c72956f
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/TagGridSkeleton.svelte
@@ -0,0 +1,50 @@
+
+
+
+ {#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
new file mode 100644
index 000000000..82732122c
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/skeletons/index.ts
@@ -0,0 +1,42 @@
+/**
+ * Contacts App Skeleton Components
+ *
+ * App-specific skeleton loaders that match the exact layout of contact components.
+ * Built on top of @manacore/shared-ui skeleton primitives.
+ */
+
+// Contact List/Grid Skeletons
+export { default as ContactRowSkeleton } from './ContactRowSkeleton.svelte';
+export { default as ContactListSkeleton } from './ContactListSkeleton.svelte';
+export { default as ContactCardSkeleton } from './ContactCardSkeleton.svelte';
+export { default as ContactGridSkeleton } from './ContactGridSkeleton.svelte';
+
+// Tag Skeletons
+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';
+
+// App Loading Skeleton
+export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
+
+// Import Preview Skeleton
+export { default as ImportPreviewSkeleton } from './ImportPreviewSkeleton.svelte';
+
+// Google Import Skeleton
+export { default as GoogleImportSkeleton } from './GoogleImportSkeleton.svelte';
+
+// Contact Detail Skeleton
+export { default as ContactDetailSkeleton } from './ContactDetailSkeleton.svelte';
+
+// Contact Notes Skeleton
+export { default as ContactNotesSkeleton } from './ContactNotesSkeleton.svelte';
+
+// Network Graph Skeleton
+export { default as NetworkGraphSkeleton } from './NetworkGraphSkeleton.svelte';
diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte
new file mode 100644
index 000000000..6fdc07007
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/views/ContactAlphabetView.svelte
@@ -0,0 +1,505 @@
+
+
+
+
+
+ {#each availableLetters as letter}
+
+ {/each}
+
+
+
+
+ {#each alphabet as letter}
+
+ {/each}
+ {#if availableLetters.includes('#')}
+
+ {/if}
+
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactGridView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactGridView.svelte
new file mode 100644
index 000000000..8fb5e20fa
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/views/ContactGridView.svelte
@@ -0,0 +1,338 @@
+
+
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte b/apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte
new file mode 100644
index 000000000..279dde6c6
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/components/views/ContactListView.svelte
@@ -0,0 +1,157 @@
+
+
+
+ {#each contacts as contact (contact.id)}
+
onContactClick(contact.id)}
+ onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
+ class="contact-card w-full text-left cursor-pointer {selectionMode &&
+ selectedIds.has(contact.id)
+ ? 'selected'
+ : ''}"
+ >
+
+ {#if selectionMode}
+
+ {/if}
+
+
+
+ {#if contact.photoUrl}
+

+ {:else}
+ {getInitials(contact)}
+ {/if}
+
+
+
+
+
+ {getDisplayName(contact)}
+
+ {#if contact.company || contact.jobTitle}
+
+ {[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
+
+ {/if}
+ {#if contact.email}
+
+ {contact.email}
+
+ {/if}
+
+
+
+
+
+ {/each}
+
+
+
diff --git a/apps/contacts/apps/web/src/lib/content/help/index.ts b/apps/contacts/apps/web/src/lib/content/help/index.ts
new file mode 100644
index 000000000..d1d759860
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/content/help/index.ts
@@ -0,0 +1,173 @@
+/**
+ * Central help content loader for Contacts app
+ * This file loads and merges the central help content from @manacore/shared-help-content
+ */
+
+import type { HelpContent } from '@manacore/shared-help-types';
+import { createEmptyContent } from '@manacore/shared-help-content';
+
+/**
+ * Central help content that applies to all Manacore apps
+ * In a production setup, this would be loaded from the shared-help-content package's
+ * Markdown files. For now, we provide the content inline for simplicity.
+ */
+export const centralHelpContent: HelpContent = {
+ faq: [
+ // Account FAQs
+ {
+ id: 'faq-account-001',
+ question: 'How do I create an account?',
+ answer: `
Creating an account is simple:
+
+ - Click the Sign Up button on the login page
+ - Enter your email address and choose a secure password
+ - Verify your email address by clicking the link we send you
+ - Complete your profile setup
+
+
You can also sign up using your Google or Apple account for faster registration.
`,
+ category: 'account',
+ order: 1,
+ language: 'en',
+ featured: true,
+ tags: ['account', 'registration', 'signup'],
+ },
+ {
+ id: 'faq-account-001',
+ question: 'Wie erstelle ich ein Konto?',
+ answer: `
Die Kontoerstellung ist einfach:
+
+ - Klicke auf Registrieren auf der Anmeldeseite
+ - Gib deine E-Mail-Adresse ein und wähle ein sicheres Passwort
+ - Bestätige deine E-Mail-Adresse durch Klick auf den Link, den wir dir senden
+ - Vervollständige dein Profil
+
+
Du kannst dich auch mit deinem Google- oder Apple-Konto registrieren, um schneller loszulegen.
`,
+ category: 'account',
+ order: 1,
+ language: 'de',
+ featured: true,
+ tags: ['konto', 'registrierung', 'anmeldung'],
+ },
+ // Billing FAQs
+ {
+ id: 'faq-billing-001',
+ question: 'How do I cancel my subscription?',
+ answer: `
You can cancel your subscription at any time:
+
+ - Go to Settings > Subscription
+ - Click Manage Subscription
+ - Select Cancel Subscription
+ - Confirm your cancellation
+
+
Your subscription will remain active until the end of the current billing period. You won't be charged again after cancellation.
`,
+ category: 'billing',
+ order: 1,
+ language: 'en',
+ featured: true,
+ tags: ['subscription', 'cancel', 'billing'],
+ },
+ {
+ id: 'faq-billing-001',
+ question: 'Wie kann ich mein Abo kündigen?',
+ answer: `
Du kannst dein Abo jederzeit kündigen:
+
+ - Gehe zu Einstellungen > Abonnement
+ - Klicke auf Abo verwalten
+ - Wähle Abo kündigen
+ - Bestätige die Kündigung
+
+
Dein Abo bleibt bis zum Ende des aktuellen Abrechnungszeitraums aktiv. Nach der Kündigung erfolgen keine weiteren Abbuchungen.
`,
+ category: 'billing',
+ order: 1,
+ language: 'de',
+ featured: true,
+ tags: ['abo', 'kündigung', 'abrechnung'],
+ },
+ // Privacy FAQs
+ {
+ id: 'faq-privacy-001',
+ question: 'How is my data protected?',
+ answer: `
We take your privacy seriously:
+
+ - Encryption: All data is encrypted in transit (TLS) and at rest
+ - GDPR Compliant: We follow EU data protection regulations
+ - No Data Selling: We never sell your personal data to third parties
+ - Data Export: You can export all your data at any time
+ - Account Deletion: You can permanently delete your account and all associated data
+
`,
+ category: 'privacy',
+ order: 1,
+ language: 'en',
+ featured: true,
+ tags: ['privacy', 'data', 'security', 'gdpr'],
+ },
+ {
+ id: 'faq-privacy-001',
+ question: 'Wie werden meine Daten geschützt?',
+ answer: `
Wir nehmen deinen Datenschutz ernst:
+
+ - Verschlüsselung: Alle Daten werden bei der Übertragung (TLS) und im Ruhezustand verschlüsselt
+ - DSGVO-konform: Wir halten uns an die EU-Datenschutzverordnung
+ - Kein Datenverkauf: Wir verkaufen niemals deine persönlichen Daten an Dritte
+ - Datenexport: Du kannst jederzeit alle deine Daten exportieren
+ - Kontolöschung: Du kannst dein Konto und alle zugehörigen Daten dauerhaft löschen
+
`,
+ category: 'privacy',
+ order: 1,
+ language: 'de',
+ featured: true,
+ tags: ['datenschutz', 'daten', 'sicherheit', 'dsgvo'],
+ },
+ ],
+ features: [],
+ shortcuts: [],
+ gettingStarted: [
+ {
+ id: 'guide-welcome',
+ title: 'Getting Started',
+ description: 'Learn the basics and get up and running quickly',
+ content: `
Create Your Account
+
Start by creating your free account. You can sign up with your email address or use Google/Apple sign-in for a faster setup.
+
Explore the Dashboard
+
After logging in, you'll see your dashboard. This is your home base where you can access all features and see important information at a glance.
+
Customize Your Settings
+
Visit the Settings page to personalize your experience.
`,
+ difficulty: 'beginner',
+ estimatedTime: '5 minutes',
+ order: 1,
+ language: 'en',
+ },
+ {
+ id: 'guide-welcome',
+ title: 'Erste Schritte',
+ description: 'Lerne die Grundlagen und starte schnell durch',
+ content: `
Konto erstellen
+
Beginne mit der Erstellung deines kostenlosen Kontos. Du kannst dich mit deiner E-Mail-Adresse registrieren oder Google/Apple für eine schnellere Anmeldung nutzen.
+
Dashboard erkunden
+
Nach dem Einloggen siehst du dein Dashboard. Dies ist deine Zentrale, von der aus du auf alle Funktionen zugreifen kannst.
+
Einstellungen anpassen
+
Besuche die Einstellungen, um dein Erlebnis zu personalisieren.
`,
+ difficulty: 'beginner',
+ estimatedTime: '5 Minuten',
+ order: 1,
+ language: 'de',
+ },
+ ],
+ changelog: [],
+ contact: {
+ id: 'contact-support',
+ title: 'Contact Support',
+ content: `
Need Help?
+
Our support team is here to help you with any questions or issues.
+
Before Contacting Us
+
+ - Check the FAQ section for quick answers
+ - Browse our Getting Started guides
+ - Search the help center using the search bar
+
`,
+ language: 'en',
+ order: 1,
+ supportEmail: 'support@manacore.app',
+ responseTime: 'Usually within 24 hours',
+ },
+};
diff --git a/apps/contacts/apps/web/src/lib/i18n/locales/de.json b/apps/contacts/apps/web/src/lib/i18n/locales/de.json
index 6e3dd5128..3fa3eea7b 100644
--- a/apps/contacts/apps/web/src/lib/i18n/locales/de.json
+++ b/apps/contacts/apps/web/src/lib/i18n/locales/de.json
@@ -4,11 +4,13 @@
},
"common": {
"back": "Zurück",
- "cancel": "Abbrechen"
+ "cancel": "Abbrechen",
+ "loadingMore": "Lade weitere..."
},
"nav": {
"contacts": "Kontakte",
"groups": "Gruppen",
+ "tags": "Tags",
"favorites": "Favoriten",
"archive": "Archiv",
"search": "Suche",
@@ -67,7 +69,22 @@
"noContacts": "Keine Kontakte gefunden",
"addFirst": "Füge deinen ersten Kontakt hinzu",
"favorites": "Favoriten",
- "archive": "Archiv"
+ "archive": "Archiv",
+ "contact": "Kontakt",
+ "contactsPlural": "Kontakte",
+ "call": "Anrufen",
+ "email": "E-Mail senden",
+ "favorite": "Als Favorit markieren",
+ "unfavorite": "Favorit entfernen"
+ },
+ "views": {
+ "list": "Listenansicht",
+ "grid": "Kachelansicht",
+ "alphabet": "Alphabetisch"
+ },
+ "sort": {
+ "firstName": "Vorname",
+ "lastName": "Nachname"
},
"contact": {
"firstName": "Vorname",
@@ -120,6 +137,29 @@
"contacts": "Kontakte",
"loadMore": "Mehr laden"
},
+ "filters": {
+ "title": "Filter",
+ "clearAll": "Alle löschen",
+ "tag": "Tag",
+ "allTags": "Alle Tags",
+ "contactInfo": "Kontaktinfo",
+ "contact": {
+ "all": "Alle Kontakte",
+ "favorites": "Favoriten",
+ "hasPhone": "Mit Telefon",
+ "hasEmail": "Mit E-Mail",
+ "incomplete": "Unvollständig"
+ },
+ "birthdayLabel": "Geburtstag",
+ "birthday": {
+ "all": "Alle",
+ "today": "Heute",
+ "thisWeek": "Diese Woche",
+ "thisMonth": "Diesen Monat"
+ },
+ "company": "Firma",
+ "allCompanies": "Alle Firmen"
+ },
"export": {
"title": "Kontakte exportieren",
"button": "Exportieren",
@@ -129,5 +169,34 @@
"includeArchived": "Archivierte Kontakte einschließen",
"exporting": "Exportiere...",
"success": "Export erfolgreich"
+ },
+ "notes": {
+ "title": "Notizen",
+ "add": "Notiz hinzufügen",
+ "addFirst": "Erste Notiz hinzufügen",
+ "empty": "Noch keine Notizen",
+ "placeholder": "Schreibe eine Notiz...",
+ "confirmDelete": "Diese Notiz löschen?",
+ "pin": "Notiz anheften",
+ "unpin": "Nicht mehr anheften",
+ "yesterday": "Gestern"
+ },
+ "tags": {
+ "title": "Tags",
+ "new": "Neuer Tag",
+ "edit": "Tag bearbeiten",
+ "noTags": "Noch keine Tags",
+ "createFirst": "Erstelle deinen ersten Tag um Kontakte zu organisieren",
+ "search": "Tags durchsuchen...",
+ "name": "Name",
+ "namePlaceholder": "Tag-Name eingeben",
+ "color": "Farbe",
+ "preview": "Vorschau",
+ "contactCount": "{count} Kontakte",
+ "confirmDelete": "Möchtest du \"{name}\" wirklich löschen?",
+ "noResults": "Keine Tags gefunden",
+ "noResultsFor": "Keine Ergebnisse für \"{query}\"",
+ "tagSingular": "Tag",
+ "tagPlural": "Tags"
}
}
diff --git a/apps/contacts/apps/web/src/lib/i18n/locales/en.json b/apps/contacts/apps/web/src/lib/i18n/locales/en.json
index 99f517c22..94a8426f5 100644
--- a/apps/contacts/apps/web/src/lib/i18n/locales/en.json
+++ b/apps/contacts/apps/web/src/lib/i18n/locales/en.json
@@ -4,11 +4,13 @@
},
"common": {
"back": "Back",
- "cancel": "Cancel"
+ "cancel": "Cancel",
+ "loadingMore": "Loading more..."
},
"nav": {
"contacts": "Contacts",
"groups": "Groups",
+ "tags": "Tags",
"favorites": "Favorites",
"archive": "Archive",
"search": "Search",
@@ -67,7 +69,22 @@
"noContacts": "No contacts found",
"addFirst": "Add your first contact",
"favorites": "Favorites",
- "archive": "Archive"
+ "archive": "Archive",
+ "contact": "Contact",
+ "contactsPlural": "Contacts",
+ "call": "Call",
+ "email": "Send email",
+ "favorite": "Mark as favorite",
+ "unfavorite": "Remove favorite"
+ },
+ "views": {
+ "list": "List view",
+ "grid": "Grid view",
+ "alphabet": "Alphabetical"
+ },
+ "sort": {
+ "firstName": "First Name",
+ "lastName": "Last Name"
},
"contact": {
"firstName": "First Name",
@@ -120,6 +137,29 @@
"contacts": "Contacts",
"loadMore": "Load more"
},
+ "filters": {
+ "title": "Filters",
+ "clearAll": "Clear all",
+ "tag": "Tag",
+ "allTags": "All tags",
+ "contactInfo": "Contact info",
+ "contact": {
+ "all": "All contacts",
+ "favorites": "Favorites",
+ "hasPhone": "With phone",
+ "hasEmail": "With email",
+ "incomplete": "Incomplete"
+ },
+ "birthdayLabel": "Birthday",
+ "birthday": {
+ "all": "All",
+ "today": "Today",
+ "thisWeek": "This week",
+ "thisMonth": "This month"
+ },
+ "company": "Company",
+ "allCompanies": "All companies"
+ },
"export": {
"title": "Export Contacts",
"button": "Export",
@@ -129,5 +169,34 @@
"includeArchived": "Include archived contacts",
"exporting": "Exporting...",
"success": "Export successful"
+ },
+ "notes": {
+ "title": "Notes",
+ "add": "Add Note",
+ "addFirst": "Add your first note",
+ "empty": "No notes yet",
+ "placeholder": "Write a note...",
+ "confirmDelete": "Delete this note?",
+ "pin": "Pin note",
+ "unpin": "Unpin note",
+ "yesterday": "Yesterday"
+ },
+ "tags": {
+ "title": "Tags",
+ "new": "New Tag",
+ "edit": "Edit Tag",
+ "noTags": "No tags yet",
+ "createFirst": "Create your first tag to organize contacts",
+ "search": "Search tags...",
+ "name": "Name",
+ "namePlaceholder": "Enter tag name",
+ "color": "Color",
+ "preview": "Preview",
+ "contactCount": "{count} contacts",
+ "confirmDelete": "Are you sure you want to delete \"{name}\"?",
+ "noResults": "No tags found",
+ "noResultsFor": "No results for \"{query}\"",
+ "tagSingular": "Tag",
+ "tagPlural": "Tags"
}
}
diff --git a/apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts b/apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts
index 92d6452e3..38d498433 100644
--- a/apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts
+++ b/apps/contacts/apps/web/src/lib/stores/contacts.svelte.ts
@@ -5,13 +5,19 @@
import { contactsApi } from '$lib/api/contacts';
import type { Contact, ContactFilters } from '$lib/api/contacts';
+// Default page size for pagination
+const DEFAULT_PAGE_SIZE = 50;
+
// State
let contacts = $state
([]);
let selectedContact = $state(null);
let loading = $state(false);
+let loadingMore = $state(false);
let error = $state(null);
let total = $state(0);
let filters = $state({});
+let hasMore = $state(true);
+let currentOffset = $state(0);
export const contactsStore = {
// Getters
@@ -24,6 +30,9 @@ export const contactsStore = {
get loading() {
return loading;
},
+ get loadingMore() {
+ return loadingMore;
+ },
get error() {
return error;
},
@@ -33,9 +42,12 @@ export const contactsStore = {
get filters() {
return filters;
},
+ get hasMore() {
+ return hasMore;
+ },
/**
- * Load contacts with optional filters
+ * Load contacts with optional filters (resets to first page)
*/
async loadContacts(newFilters?: ContactFilters) {
if (newFilters) {
@@ -44,11 +56,18 @@ export const contactsStore = {
loading = true;
error = null;
+ currentOffset = 0;
try {
- const result = await contactsApi.list(filters);
+ const result = await contactsApi.list({
+ ...filters,
+ limit: DEFAULT_PAGE_SIZE,
+ offset: 0,
+ });
contacts = result.contacts;
total = result.total;
+ hasMore = contacts.length < total;
+ currentOffset = contacts.length;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load contacts';
console.error('Failed to load contacts:', e);
@@ -57,6 +76,35 @@ export const contactsStore = {
}
},
+ /**
+ * Load more contacts (infinite scroll)
+ */
+ async loadMore() {
+ if (loadingMore || !hasMore) return;
+
+ loadingMore = true;
+ error = null;
+
+ try {
+ const result = await contactsApi.list({
+ ...filters,
+ limit: DEFAULT_PAGE_SIZE,
+ offset: currentOffset,
+ });
+
+ const newContacts = result.contacts;
+ contacts = [...contacts, ...newContacts];
+ total = result.total;
+ currentOffset += newContacts.length;
+ hasMore = contacts.length < total;
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to load more contacts';
+ console.error('Failed to load more contacts:', e);
+ } finally {
+ loadingMore = false;
+ }
+ },
+
/**
* Load a single contact by ID
*/
@@ -199,6 +247,13 @@ export const contactsStore = {
filters = { ...filters, search };
},
+ /**
+ * Set tag filter
+ */
+ setTagId(tagId: string | undefined) {
+ filters = { ...filters, tagId };
+ },
+
/**
* Clear selected contact
*/
diff --git a/apps/contacts/apps/web/src/lib/stores/custom-themes.svelte.ts b/apps/contacts/apps/web/src/lib/stores/custom-themes.svelte.ts
new file mode 100644
index 000000000..1103c1cd1
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/stores/custom-themes.svelte.ts
@@ -0,0 +1,15 @@
+/**
+ * Custom Themes Store - Manages user's custom themes and community themes
+ */
+
+import { createCustomThemesStore } from '@manacore/shared-theme';
+import { authStore } from './auth.svelte';
+
+// Auth URL for theme API calls
+const MANA_AUTH_URL = 'http://localhost:3001';
+
+// Create the custom themes store
+export const customThemesStore = createCustomThemesStore({
+ authUrl: MANA_AUTH_URL,
+ getAccessToken: () => authStore.getAccessToken(),
+});
diff --git a/apps/contacts/apps/web/src/lib/stores/network.svelte.ts b/apps/contacts/apps/web/src/lib/stores/network.svelte.ts
new file mode 100644
index 000000000..777b6141d
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/stores/network.svelte.ts
@@ -0,0 +1,445 @@
+/**
+ * Network Store - Manages network graph state with D3-force simulation
+ */
+
+import { browser } from '$app/environment';
+import { networkApi } from '$lib/api/network';
+import type { NetworkNode, NetworkLink } from '$lib/api/network';
+import {
+ forceSimulation,
+ forceLink,
+ forceManyBody,
+ forceCenter,
+ forceCollide,
+ type Simulation,
+} from 'd3-force';
+import type {
+ SimulationNode as SharedSimulationNode,
+ SimulationLink as SharedSimulationLink,
+} from '@manacore/shared-ui';
+
+// Re-export types from shared-ui for convenience
+export type SimulationNode = SharedSimulationNode;
+export type SimulationLink = SharedSimulationLink;
+
+// State
+let nodes = $state([]);
+let links = $state([]);
+let loading = $state(false);
+let error = $state(null);
+let selectedNodeId = $state(null);
+let simulation: Simulation | null = null;
+let searchQuery = $state('');
+let filterTagId = $state(null);
+let filterCompany = $state(null);
+let minStrength = $state(0);
+let tickCounter = $state(0); // Used to trigger reactivity on simulation tick
+let simulationInitialized = false;
+let dataLoaded = false; // Prevent double loading
+let lastDimensions = { width: 0, height: 0 };
+
+// Derived state for filtering
+const filteredNodes = $derived.by(() => {
+ let result = nodes;
+
+ // Search filter
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase();
+ result = result.filter(
+ (node) =>
+ node.name.toLowerCase().includes(query) ||
+ node.subtitle?.toLowerCase().includes(query) ||
+ node.tags.some((t) => t.name.toLowerCase().includes(query))
+ );
+ }
+
+ // Tag filter
+ if (filterTagId) {
+ result = result.filter((node) => node.tags.some((t) => t.id === filterTagId));
+ }
+
+ // Company filter (uses subtitle field)
+ if (filterCompany) {
+ result = result.filter((node) => node.subtitle === filterCompany);
+ }
+
+ return result;
+});
+
+const filteredLinks = $derived.by(() => {
+ const filteredNodeIds = new Set(filteredNodes.map((n) => n.id));
+ return links.filter((link) => {
+ const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
+ const targetId = typeof link.target === 'string' ? link.target : link.target.id;
+ // Check if both nodes are visible and strength meets minimum
+ if (!filteredNodeIds.has(sourceId) || !filteredNodeIds.has(targetId)) {
+ return false;
+ }
+ // Filter by minimum strength
+ if (minStrength > 0 && link.strength < minStrength) {
+ return false;
+ }
+ return true;
+ });
+});
+
+// Get unique companies for filter dropdown (uses subtitle field)
+const uniqueCompanies = $derived.by(() => {
+ const companies = new Set();
+ for (const node of nodes) {
+ if (node.subtitle) {
+ companies.add(node.subtitle);
+ }
+ }
+ return Array.from(companies).sort();
+});
+
+// Get unique tags for filter dropdown
+const uniqueTags = $derived.by(() => {
+ const tagsMap = new Map();
+ for (const node of nodes) {
+ for (const tag of node.tags) {
+ if (!tagsMap.has(tag.id)) {
+ tagsMap.set(tag.id, tag);
+ }
+ }
+ }
+ return Array.from(tagsMap.values()).sort((a, b) => a.name.localeCompare(b.name));
+});
+
+export const networkStore = {
+ // Getters
+ get nodes() {
+ // Access tickCounter to trigger reactivity on simulation updates
+ void tickCounter;
+ return filteredNodes;
+ },
+ get allNodes() {
+ void tickCounter;
+ return nodes;
+ },
+ get links() {
+ void tickCounter;
+ return filteredLinks;
+ },
+ get allLinks() {
+ void tickCounter;
+ return links;
+ },
+ get tick() {
+ return tickCounter;
+ },
+ get loading() {
+ return loading;
+ },
+ get error() {
+ return error;
+ },
+ get selectedNodeId() {
+ return selectedNodeId;
+ },
+ get selectedNode() {
+ return nodes.find((n) => n.id === selectedNodeId) || null;
+ },
+ get searchQuery() {
+ return searchQuery;
+ },
+ get filterTagId() {
+ return filterTagId;
+ },
+ get filterCompany() {
+ return filterCompany;
+ },
+ get minStrength() {
+ return minStrength;
+ },
+ get uniqueCompanies() {
+ return uniqueCompanies;
+ },
+ get uniqueTags() {
+ return uniqueTags;
+ },
+
+ /**
+ * Load network graph data from API
+ */
+ async loadGraph(force = false) {
+ // Prevent double loading
+ if (dataLoaded && !force) {
+ console.log('[Network] Data already loaded, skipping');
+ return;
+ }
+
+ if (loading) {
+ console.log('[Network] Already loading, skipping');
+ return;
+ }
+
+ loading = true;
+ error = null;
+
+ // Reset simulation state for fresh data
+ if (simulation) {
+ simulation.stop();
+ simulation = null;
+ }
+ simulationInitialized = false;
+
+ try {
+ const response = await networkApi.getGraph();
+
+ console.log(
+ '[Network] Loaded',
+ response.nodes.length,
+ 'nodes and',
+ response.links.length,
+ 'links'
+ );
+
+ // Convert to simulation nodes with subtitle for company
+ nodes = response.nodes.map((node) => ({
+ ...node,
+ subtitle: node.company, // Map company to subtitle for shared component
+ x: undefined,
+ y: undefined,
+ vx: undefined,
+ vy: undefined,
+ fx: null,
+ fy: null,
+ }));
+
+ // Convert to simulation links
+ links = response.links.map((link) => ({
+ source: link.source,
+ target: link.target,
+ type: link.type,
+ strength: link.strength,
+ sharedTags: link.sharedTags,
+ }));
+
+ dataLoaded = true;
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Failed to load network graph';
+ console.error('Failed to load network graph:', e);
+ } finally {
+ loading = false;
+ }
+ },
+
+ /**
+ * Initialize D3 force simulation
+ */
+ initSimulation(width: number, height: number) {
+ if (!browser) return;
+ if (nodes.length === 0) return;
+ if (width <= 0 || height <= 0) return;
+
+ // Prevent re-initialization if already running
+ if (simulationInitialized && simulation) {
+ // Only update center if dimensions changed significantly
+ if (
+ Math.abs(lastDimensions.width - width) > 50 ||
+ Math.abs(lastDimensions.height - height) > 50
+ ) {
+ console.log('[Network] Updating simulation center for new dimensions:', width, 'x', height);
+ lastDimensions = { width, height };
+ this.updateSimulationCenter(width, height);
+ }
+ return;
+ }
+
+ // Stop existing simulation
+ if (simulation) {
+ simulation.stop();
+ }
+
+ console.log(
+ '[Network] Initializing simulation with',
+ nodes.length,
+ 'nodes, dimensions:',
+ width,
+ 'x',
+ height
+ );
+ lastDimensions = { width, height };
+
+ // Initialize node positions spread around the center
+ const centerX = width / 2;
+ const centerY = height / 2;
+ const radius = Math.min(width, height) / 3;
+
+ nodes.forEach((node, i) => {
+ // Only set initial position if not already set
+ if (node.x === undefined || node.y === undefined) {
+ // Spread nodes in a circle initially
+ const angle = (i / nodes.length) * 2 * Math.PI;
+ const r = radius * (0.5 + Math.random() * 0.5);
+ node.x = centerX + r * Math.cos(angle);
+ node.y = centerY + r * Math.sin(angle);
+ }
+ });
+
+ // Create new simulation
+ simulation = forceSimulation(nodes)
+ .force(
+ 'link',
+ forceLink(links)
+ .id((d) => d.id)
+ .distance(100) // Fixed distance for cleaner layout
+ .strength(0.5)
+ )
+ .force('charge', forceManyBody().strength(-300))
+ .force('center', forceCenter(centerX, centerY))
+ .force('collision', forceCollide().radius(50))
+ .on('tick', () => {
+ // Trigger Svelte reactivity by incrementing counter
+ tickCounter++;
+ });
+
+ simulationInitialized = true;
+
+ // Run simulation with higher alpha for better initial spread
+ simulation.alpha(1).restart();
+ },
+
+ /**
+ * Update simulation dimensions (e.g., on window resize)
+ */
+ updateSimulationCenter(width: number, height: number) {
+ if (simulation) {
+ simulation.force('center', forceCenter(width / 2, height / 2));
+ simulation.alpha(0.3).restart();
+ }
+ },
+
+ /**
+ * Stop the simulation
+ */
+ stopSimulation() {
+ if (simulation) {
+ simulation.stop();
+ simulation = null;
+ }
+ simulationInitialized = false;
+ // Don't reset dataLoaded here - only reset when navigating away
+ },
+
+ /**
+ * Reset the store completely (call when leaving the page)
+ */
+ reset() {
+ this.stopSimulation();
+ nodes = [];
+ links = [];
+ dataLoaded = false;
+ lastDimensions = { width: 0, height: 0 };
+ tickCounter = 0;
+ },
+
+ /**
+ * Reheat simulation (restart with some energy)
+ */
+ reheatSimulation() {
+ if (simulation) {
+ simulation.alpha(0.3).restart();
+ }
+ },
+
+ /**
+ * Fix node position (for dragging)
+ */
+ fixNode(nodeId: string, x: number, y: number) {
+ const node = nodes.find((n) => n.id === nodeId);
+ if (node) {
+ node.fx = x;
+ node.fy = y;
+ }
+ },
+
+ /**
+ * Release node (after dragging)
+ */
+ releaseNode(nodeId: string) {
+ const node = nodes.find((n) => n.id === nodeId);
+ if (node) {
+ node.fx = null;
+ node.fy = null;
+ }
+ },
+
+ /**
+ * Select a node
+ */
+ selectNode(nodeId: string | null) {
+ selectedNodeId = nodeId;
+ },
+
+ /**
+ * Set search query
+ */
+ setSearch(query: string) {
+ searchQuery = query;
+ },
+
+ /**
+ * Set tag filter
+ */
+ setFilterTag(tagId: string | null) {
+ filterTagId = tagId;
+ },
+
+ /**
+ * Set company filter
+ */
+ setFilterCompany(company: string | null) {
+ filterCompany = company;
+ },
+
+ /**
+ * Set minimum strength filter
+ */
+ setMinStrength(strength: number) {
+ minStrength = strength;
+ },
+
+ /**
+ * Clear all filters
+ */
+ clearFilters() {
+ searchQuery = '';
+ filterTagId = null;
+ filterCompany = null;
+ minStrength = 0;
+ },
+
+ /**
+ * Get connected nodes for a given node
+ */
+ getConnectedNodes(nodeId: string): SimulationNode[] {
+ const connectedIds = new Set();
+
+ for (const link of links) {
+ const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
+ const targetId = typeof link.target === 'string' ? link.target : link.target.id;
+
+ if (sourceId === nodeId) {
+ connectedIds.add(targetId);
+ } else if (targetId === nodeId) {
+ connectedIds.add(sourceId);
+ }
+ }
+
+ return nodes.filter((n) => connectedIds.has(n.id));
+ },
+
+ /**
+ * Get links for a given node
+ */
+ getNodeLinks(nodeId: string): SimulationLink[] {
+ return links.filter((link) => {
+ const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
+ const targetId = typeof link.target === 'string' ? link.target : link.target.id;
+ return sourceId === nodeId || targetId === nodeId;
+ });
+ },
+};
diff --git a/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts
new file mode 100644
index 000000000..f5d679f13
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/stores/settings.svelte.ts
@@ -0,0 +1,228 @@
+/**
+ * Settings Store - Manages user preferences for the Contacts app
+ * Uses Svelte 5 runes and localStorage for persistence
+ */
+
+import { browser } from '$app/environment';
+
+// Settings types
+export type ContactSortBy = 'name' | 'company' | 'created' | 'updated';
+export type ContactSortOrder = 'asc' | 'desc';
+export type ContactView = 'list' | 'grid' | 'alphabet';
+export type DateFormat = 'dd.MM.yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd';
+
+export interface ContactsAppSettings {
+ // Display Settings
+ /** Default view mode for contacts list */
+ defaultView: ContactView;
+ /** Default sort field */
+ sortBy: ContactSortBy;
+ /** Default sort order */
+ sortOrder: ContactSortOrder;
+ /** Show contact photos in list */
+ showPhotos: boolean;
+ /** Show company name in list */
+ showCompany: boolean;
+ /** Contacts per page in list view */
+ contactsPerPage: number;
+
+ // Contact Display
+ /** Display name format: 'first-last' or 'last-first' */
+ nameFormat: 'first-last' | 'last-first';
+ /** Date format for birthdays etc. */
+ dateFormat: DateFormat;
+ /** Show birthday reminders */
+ showBirthdayReminders: boolean;
+ /** Days before birthday to remind */
+ birthdayReminderDays: number;
+
+ // Import/Export
+ /** Default export format */
+ defaultExportFormat: 'vcf' | 'csv' | 'json';
+ /** Include notes in export */
+ includeNotesInExport: boolean;
+ /** Include photos in export */
+ includePhotosInExport: boolean;
+
+ // Duplicates
+ /** Auto-detect duplicates on import */
+ autoDetectDuplicates: boolean;
+ /** Duplicate detection sensitivity: 'strict' | 'normal' | 'loose' */
+ duplicateSensitivity: 'strict' | 'normal' | 'loose';
+
+ // Privacy
+ /** Blur contact photos by default (privacy mode) */
+ privacyMode: boolean;
+ /** Require confirmation before sharing contact */
+ confirmBeforeSharing: boolean;
+}
+
+const DEFAULT_SETTINGS: ContactsAppSettings = {
+ // Display Settings
+ defaultView: 'alphabet',
+ sortBy: 'name',
+ sortOrder: 'asc',
+ showPhotos: true,
+ showCompany: true,
+ contactsPerPage: 50,
+
+ // Contact Display
+ nameFormat: 'first-last',
+ dateFormat: 'dd.MM.yyyy',
+ showBirthdayReminders: true,
+ birthdayReminderDays: 7,
+
+ // Import/Export
+ defaultExportFormat: 'vcf',
+ includeNotesInExport: true,
+ includePhotosInExport: true,
+
+ // Duplicates
+ autoDetectDuplicates: true,
+ duplicateSensitivity: 'normal',
+
+ // Privacy
+ privacyMode: false,
+ confirmBeforeSharing: true,
+};
+
+const STORAGE_KEY = 'contacts-settings';
+
+// Load settings from localStorage
+function loadSettings(): ContactsAppSettings {
+ if (!browser) return DEFAULT_SETTINGS;
+
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ // Merge with defaults to handle new settings added in updates
+ return { ...DEFAULT_SETTINGS, ...parsed };
+ }
+ } catch (e) {
+ console.error('Failed to load contacts settings:', e);
+ }
+
+ return DEFAULT_SETTINGS;
+}
+
+// Save settings to localStorage
+function saveSettings(settings: ContactsAppSettings) {
+ if (!browser) return;
+
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
+ } catch (e) {
+ console.error('Failed to save contacts settings:', e);
+ }
+}
+
+// State
+let settings = $state(loadSettings());
+
+export const contactsSettings = {
+ // Full settings object
+ get settings() {
+ return settings;
+ },
+
+ // Display Settings
+ get defaultView() {
+ return settings.defaultView;
+ },
+ get sortBy() {
+ return settings.sortBy;
+ },
+ get sortOrder() {
+ return settings.sortOrder;
+ },
+ get showPhotos() {
+ return settings.showPhotos;
+ },
+ get showCompany() {
+ return settings.showCompany;
+ },
+ get contactsPerPage() {
+ return settings.contactsPerPage;
+ },
+
+ // Contact Display
+ get nameFormat() {
+ return settings.nameFormat;
+ },
+ get dateFormat() {
+ return settings.dateFormat;
+ },
+ get showBirthdayReminders() {
+ return settings.showBirthdayReminders;
+ },
+ get birthdayReminderDays() {
+ return settings.birthdayReminderDays;
+ },
+
+ // Import/Export
+ get defaultExportFormat() {
+ return settings.defaultExportFormat;
+ },
+ get includeNotesInExport() {
+ return settings.includeNotesInExport;
+ },
+ get includePhotosInExport() {
+ return settings.includePhotosInExport;
+ },
+
+ // Duplicates
+ get autoDetectDuplicates() {
+ return settings.autoDetectDuplicates;
+ },
+ get duplicateSensitivity() {
+ return settings.duplicateSensitivity;
+ },
+
+ // Privacy
+ get privacyMode() {
+ return settings.privacyMode;
+ },
+ get confirmBeforeSharing() {
+ return settings.confirmBeforeSharing;
+ },
+
+ /**
+ * Initialize settings from localStorage
+ */
+ initialize() {
+ if (!browser) return;
+ settings = loadSettings();
+ },
+
+ /**
+ * Update a single setting
+ */
+ set(key: K, value: ContactsAppSettings[K]) {
+ settings = { ...settings, [key]: value };
+ saveSettings(settings);
+ },
+
+ /**
+ * Update multiple settings at once
+ */
+ update(updates: Partial) {
+ settings = { ...settings, ...updates };
+ saveSettings(settings);
+ },
+
+ /**
+ * Reset all settings to defaults
+ */
+ reset() {
+ settings = { ...DEFAULT_SETTINGS };
+ saveSettings(settings);
+ },
+
+ /**
+ * Get default settings (for reference)
+ */
+ getDefaults() {
+ return DEFAULT_SETTINGS;
+ },
+};
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
new file mode 100644
index 000000000..1a61b9bbb
--- /dev/null
+++ b/apps/contacts/apps/web/src/lib/stores/view-mode.svelte.ts
@@ -0,0 +1,67 @@
+/**
+ * View Mode Store - Manages contact list view mode
+ * Syncs with contactsSettings for the default view preference
+ */
+
+import { browser } from '$app/environment';
+import { contactsSettings, type ContactView } from './settings.svelte';
+
+export type ViewMode = ContactView;
+
+const STORAGE_KEY = 'contacts-view-mode';
+
+// 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 === 'list' || sessionMode === 'grid' || sessionMode === 'alphabet') {
+ return sessionMode;
+ }
+
+ // Otherwise use the default from settings
+ return contactsSettings.defaultView || 'alphabet';
+}
+
+let mode = $state(getInitialMode());
+
+export const viewModeStore = {
+ get mode() {
+ return mode;
+ },
+
+ setMode(newMode: ViewMode) {
+ mode = newMode;
+ // Save to sessionStorage for current session
+ if (browser) {
+ sessionStorage.setItem(STORAGE_KEY, newMode);
+ }
+ },
+
+ /**
+ * Reset to default view from settings
+ */
+ resetToDefault() {
+ mode = contactsSettings.defaultView || 'alphabet';
+ if (browser) {
+ sessionStorage.removeItem(STORAGE_KEY);
+ }
+ },
+
+ /**
+ * Initialize mode from settings (call on app load)
+ */
+ initialize() {
+ if (!browser) return;
+
+ // Check if there's a session preference
+ const sessionMode = sessionStorage.getItem(STORAGE_KEY);
+ if (sessionMode === 'list' || sessionMode === 'grid' || sessionMode === 'alphabet') {
+ mode = sessionMode;
+ } else {
+ // Use default from settings
+ mode = contactsSettings.defaultView || 'alphabet';
+ }
+ },
+};
diff --git a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte
index 074157096..fe7339282 100644
--- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte
+++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte
@@ -3,12 +3,22 @@
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
- import { PillNavigation } from '@manacore/shared-ui';
- import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
+ import { PillNavigation, CommandBar } from '@manacore/shared-ui';
+ import type {
+ PillNavItem,
+ PillDropdownItem,
+ CommandBarItem,
+ QuickAction,
+ } from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
- import { THEME_DEFINITIONS } from '@manacore/shared-theme';
+ import {
+ THEME_DEFINITIONS,
+ DEFAULT_THEME_VARIANTS,
+ EXTENDED_THEME_VARIANTS,
+ } from '@manacore/shared-theme';
+ import type { ThemeVariant } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
@@ -18,6 +28,12 @@
import { setLocale, supportedLocales } from '$lib/i18n';
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
+ import { contactsApi } from '$lib/api/contacts';
+ import { viewModeStore } from '$lib/stores/view-mode.svelte';
+ import { contactsSettings } from '$lib/stores/settings.svelte';
+
+ // Search modal state
+ let searchModalOpen = $state(false);
// Check if we're on a contact detail route
const contactDetailMatch = $derived($page.url.pathname.match(/^\/contacts\/([0-9a-f-]{36})$/i));
@@ -35,10 +51,20 @@
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
+ // Get pinned themes from user settings (extended themes only)
+ let pinnedThemes = $derived(
+ (userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
+ EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
+ )
+ );
+
+ // Visible themes in PillNav: default + pinned extended
+ let visibleThemes = $derived([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
+
// Theme variant dropdown items
let themeVariantItems = $derived([
- // Theme variants
- ...theme.variants.map((variant) => ({
+ // Theme variants (only default + pinned)
+ ...visibleThemes.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,
@@ -74,10 +100,12 @@
// Navigation items for Contacts
const navItems: PillNavItem[] = [
{ href: '/', label: 'Kontakte', icon: 'users' },
- { href: '/groups', label: 'Gruppen', icon: 'folder' },
+ { href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
- { href: '/archive', label: 'Archiv', icon: 'archive' },
+ { 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' },
];
// Navigation shortcuts (Ctrl+1-5)
@@ -89,7 +117,7 @@
// Cmd/Ctrl+K to open search (works even in inputs)
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
- // TODO: Open search modal
+ searchModalOpen = true;
return;
}
@@ -144,6 +172,41 @@
goto('/', { replaceState: false });
}
+ // CommandBar search function
+ async function handleCommandBarSearch(query: string): Promise {
+ const response = await contactsApi.list({ search: query, limit: 10 });
+ return (response.contacts || []).map((contact: any) => ({
+ id: contact.id,
+ title:
+ contact.displayName ||
+ [contact.firstName, contact.lastName].filter(Boolean).join(' ') ||
+ contact.email ||
+ 'Unbekannt',
+ subtitle: contact.company || contact.email,
+ imageUrl: contact.photoUrl,
+ isFavorite: contact.isFavorite,
+ }));
+ }
+
+ // CommandBar item selection
+ function handleCommandBarSelect(item: CommandBarItem) {
+ goto(`/contacts/${item.id}`);
+ }
+
+ // CommandBar quick actions
+ const commandBarQuickActions: QuickAction[] = [
+ {
+ id: 'new',
+ label: 'Neuen Kontakt erstellen',
+ icon: 'plus',
+ href: '/contacts/new',
+ shortcut: 'N',
+ },
+ { id: 'favorites', label: 'Favoriten anzeigen', icon: 'heart', href: '/favorites' },
+ { id: 'tags', label: 'Tags verwalten', icon: 'tag', href: '/tags' },
+ { id: 'import', label: 'Kontakte importieren', icon: 'upload', href: '/data?tab=import' },
+ ];
+
onMount(async () => {
// Redirect to login if not authenticated
if (!authStore.isAuthenticated) {
@@ -154,6 +217,10 @@
// Load user settings
await userSettings.load();
+ // Initialize contacts settings and view mode
+ contactsSettings.initialize();
+ viewModeStore.initialize();
+
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('contacts-nav-sidebar');
if (savedSidebar === 'true') {
@@ -174,6 +241,9 @@
+
+
+
{@render children()}
@@ -224,6 +294,18 @@
{#if showContactModal && modalContactId}
{/if}
+
+
+ (searchModalOpen = false)}
+ onSearch={handleCommandBarSearch}
+ onSelect={handleCommandBarSelect}
+ quickActions={commandBarQuickActions}
+ placeholder="Kontakt suchen..."
+ emptyText="Keine Kontakte gefunden"
+ searchingText="Suche..."
+ />
diff --git a/apps/contacts/apps/web/src/routes/(app)/archive/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/archive/+page.svelte
index ce3e97fbf..5b8d743f8 100644
--- a/apps/contacts/apps/web/src/routes/(app)/archive/+page.svelte
+++ b/apps/contacts/apps/web/src/routes/(app)/archive/+page.svelte
@@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { contactsApi } from '$lib/api/contacts';
import type { Contact } from '$lib/api/contacts';
+ import { ContactListSkeleton } from '$lib/components/skeletons';
import '$lib/i18n';
let loading = $state(true);
@@ -140,9 +141,7 @@
{/if}
{#if loading}
-
+
{:else if contacts.length === 0}
diff --git a/apps/contacts/apps/web/src/routes/(app)/data/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/data/+page.svelte
new file mode 100644
index 000000000..b5f3afa56
--- /dev/null
+++ b/apps/contacts/apps/web/src/routes/(app)/data/+page.svelte
@@ -0,0 +1,607 @@
+
+
+
+ Daten - Kontakte
+
+
+
+
+
+
+
Daten verwalten
+
Kontakte importieren, exportieren und sichern
+
+
Zurück
+
+
+
+
+
+
+
+
+
+ {#if activeTab === 'import'}
+
+
+
+
+
+
+
+ {#if importError && importSource === 'file'}
+
+ {importError}
+
+ {/if}
+
+
+ {#if importSource === 'file'}
+ {#if importStep === 'upload'}
+
+ {#if isLoading}
+
+ {:else}
+
+
+
+
Unterstützte Formate
+
+
+
+
+
vCard (.vcf)
+
+ Standard-Format für Kontakte, kompatibel mit allen gängigen Apps
+
+
+
+
+
+
+
CSV (.csv)
+
+ Tabellen-Format, ideal für Excel oder Google Sheets
+
+
+
+
+
+
+
+
+
+ {/if}
+
+ {/if}
+
+ {#if importStep === 'preview' && preview}
+
+ {/if}
+
+ {#if importStep === 'result' && importResult}
+
+
+
+
+
Import abgeschlossen
+
Deine Kontakte wurden erfolgreich importiert
+
+
+
+
+
{importResult.imported}
+
Importiert
+
+
+
{importResult.merged}
+
Zusammengeführt
+
+
+
{importResult.skipped}
+
Übersprungen
+
+
+
+ {#if importResult.errors.length > 0}
+
+
Fehler
+
+ {#each importResult.errors as err}
+ - {err.contactName}: {err.error}
+ {/each}
+
+
+ {/if}
+
+
+
+
+
+
+ {/if}
+ {/if}
+
+
+ {#if importSource === 'google'}
+
+ {/if}
+ {/if}
+
+
+ {#if activeTab === 'export'}
+
+
+ {#if exportSuccess}
+
+
+ Export erfolgreich! Die Datei wurde heruntergeladen.
+
+ {/if}
+
+
+ {#if exportError}
+
+ {exportError}
+
+ {/if}
+
+
+
+
Format wählen
+
+
+
+
+
+
+
+
+
Filter
+
+
+
+
+
+
+
+
+
+
Optionen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/if}
+
diff --git a/apps/contacts/apps/web/src/routes/(app)/duplicates/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/duplicates/+page.svelte
new file mode 100644
index 000000000..eb4901f1b
--- /dev/null
+++ b/apps/contacts/apps/web/src/routes/(app)/duplicates/+page.svelte
@@ -0,0 +1,252 @@
+
+
+
+ Duplikate - Contacts
+
+
+
+
+
+
+
Duplikate finden
+
Finde und führe doppelte Kontakte zusammen
+
+
+
+
+
+ {#if loading}
+
+ {:else if error}
+
+
+
❌
+
Fehler beim Laden
+
{error}
+
+
+ {:else if duplicates.length === 0}
+
+
+
✨
+
Keine Duplikate gefunden
+
+ Deine Kontakte sehen sauber aus! Es wurden keine potenziellen Duplikate erkannt.
+
+
+ {:else}
+
+
+
+
{duplicates.length}
+
Duplikat-Gruppen
+
+
+
+ {duplicates.reduce((sum, d) => sum + d.contacts.length, 0)}
+
+
Betroffene Kontakte
+
+
+
+ {duplicates.reduce((sum, d) => sum + d.contacts.length - 1, 0)}
+
+
Mögliche Einsparung
+
+
+
+
+
+ {#each duplicates as group (group.id)}
+
+
+
+
+
{getMatchTypeIcon(group.matchType)}
+
+
+ {group.contacts.length} Kontakte mit gleicher {getMatchTypeLabel(group.matchType)}
+
+
+ {group.matchValue}
+
+
+
+
+
+
+
+
+
+ {#each group.contacts as contact (contact.id)}
+
+
+ {#if contact.photoUrl}
+

+ {:else}
+ {getInitials(contact)}
+ {/if}
+
+
+
+ {getDisplayName(contact)}
+
+ {#if contact.company}
+
+ {contact.company}
+
+ {/if}
+
+
+ {/each}
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+{#if selectedGroup}
+
+{/if}
diff --git a/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte
index 92ddfcfa7..528c0bed3 100644
--- a/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte
+++ b/apps/contacts/apps/web/src/routes/(app)/favorites/+page.svelte
@@ -1,16 +1,24 @@
Favoriten - Contacts
-
-
-