{@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} + {getDisplayName(contact)} + {: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 -
- -
- - - - - -

Favoriten

-
- +
+ +
+
+
+ + + +
+
+

Favoriten

+

+ {#if contacts.length === 0} + Markiere Kontakte als Favoriten für schnellen Zugriff + {:else} + {contacts.length} Favorit{contacts.length !== 1 ? 'en' : ''} für schnellen Zugriff + {/if} +

+
+
+ + + {#if contacts.length > 0} +
+
+
+ + + +
+
+ {contacts.length} + Favoriten +
+
+
+
+ + + +
+
+ {contacts.filter((c) => c.email).length} + Mit E-Mail +
+
+
+
+ + + +
+
+ {contacts.filter((c) => c.phone || c.mobile).length} + Mit Telefon +
+
+
+ {/if} +
+ + +
+ +
+ -
-
- - -
- - - - + {#if searchQuery} + + {/if} +
+ + +
+ + + +
{#if error} + + {#if viewMode === 'cards'} + + {:else} + + {/if} {:else if contacts.length === 0}
@@ -134,17 +285,18 @@

Keine Favoriten

- Markiere Kontakte als Favoriten, um sie hier schnell zu finden. + Markiere Kontakte als Favoriten, um sie hier schnell wiederzufinden. Klicke einfach auf das + Herz-Symbol bei einem Kontakt.

- - + + - Zu Kontakten + Zu allen Kontakten
- {:else if filteredContacts().length === 0} + {:else if filteredContacts.length === 0}
-
+

Keine Ergebnisse

Keine Favoriten gefunden für "{searchQuery}"

+
{:else} -
- {#each filteredContacts() as contact (contact.id)} -
handleContactClick(contact.id)} - onkeydown={(e) => e.key === 'Enter' && handleContactClick(contact.id)} - class="contact-card" - > - -
- {#if contact.photoUrl} - {getDisplayName(contact)} - {: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} + +
+ {#if viewMode === 'cards'} + + {:else if viewMode === 'list'} + + {:else} + + {/if}
-

{contacts.length} Favorit{contacts.length !== 1 ? 'en' : ''}

+ + {/if}
diff --git a/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte deleted file mode 100644 index 8d3b5ffcd..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte +++ /dev/null @@ -1,503 +0,0 @@ - - - - Gruppen - Contacts - - -
- -
- - - - - -

Gruppen

- - - - - -
- - -
- - - - -
- - {#if error} - - {/if} - - {#if loading} -
-
-
- {:else if groups.length === 0} -
-
- - - -
-

Keine Gruppen

-

Erstelle deine erste Gruppe um Kontakte zu organisieren.

- - - - - Neue Gruppe - -
- {:else if filteredGroups().length === 0} -
-
- - - -
-

Keine Ergebnisse

-

Keine Gruppen gefunden für "{searchQuery}"

-
- {:else} -
- {#each filteredGroups() as group (group.id)} -
handleGroupClick(group.id)} - onkeydown={(e) => e.key === 'Enter' && handleGroupClick(group.id)} - class="group-card" - > -
-
-

{group.name}

- {#if group.description} -

{group.description}

- {/if} -
-
- - - - -
-
- {/each} -
- -

{groups.length} Gruppe{groups.length !== 1 ? 'n' : ''}

- {/if} -
- - diff --git a/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte deleted file mode 100644 index ffc681080..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte +++ /dev/null @@ -1,1140 +0,0 @@ - - - - {group?.name || 'Gruppe'} - Contacts - - -
- -
- - - - - -

{isEditing ? 'Gruppe bearbeiten' : group?.name || 'Gruppe'}

- {#if !loading && group && !isEditing} - - {:else} -
- {/if} -
- - {#if loading} -
-
-
- {:else if error && !group} -
-
- - - -
-

Fehler

-

{error}

- Zurück zu Gruppen -
- {:else if group} - {#if error} - - {/if} - - {#if isEditing} - -
-
- - - -
-

{name || 'Gruppenname'}

-
- -
{ - e.preventDefault(); - handleSave(); - }} - class="form" - > -
-
-
- - - -
-

Details

-
-
- - -
-
- - -
-
- -
-
-
- - - -
-

Farbe

-
-
- {#each presetColors as presetColor} - - {/each} -
-
- -
- - -
-
- - - - {:else} - -
-
- - - -
-

{group.name}

- {#if group.description} -

{group.description}

- {/if} -
- - -
-
-
- - - -
-

Kontakte ({groupContacts().length})

- -
- - {#if groupContacts().length === 0} -

Keine Kontakte in dieser Gruppe

- {:else} -
- {#each groupContacts() as contact (contact.id)} -
-
- {#if contact.photoUrl} - {getDisplayName(contact)} - {:else} - {getInitials(contact)} - {/if} -
-
- {getDisplayName(contact)} - {#if contact.email} - {contact.email} - {/if} -
- -
- {/each} -
- {/if} -
- {/if} - {/if} -
- - -{#if showAddContacts} - -{/if} - - diff --git a/apps/contacts/apps/web/src/routes/(app)/groups/new/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/groups/new/+page.svelte deleted file mode 100644 index 87a2b8266..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/groups/new/+page.svelte +++ /dev/null @@ -1,581 +0,0 @@ - - - - Neue Gruppe - Contacts - - -
- -
- - - - - -

Neue Gruppe

-
-
- - -
-
- - - -
-

{name || 'Neue Gruppe'}

- {#if description} -

{description}

- {/if} -
- - {#if error} - - {/if} - -
{ - e.preventDefault(); - handleSubmit(); - }} - class="form" - > - -
-
-
- - - -
-

Gruppenname

-
-
- - -
-
- - -
-
- - -
-
-
- - - -
-

Farbe

-
-
- {#each presetColors as presetColor} - - {/each} -
-
- -
- - -
-
-
- - -
- Abbrechen - -
-
-
- - diff --git a/apps/contacts/apps/web/src/routes/(app)/help/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/help/+page.svelte new file mode 100644 index 000000000..82aa1c5e5 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/help/+page.svelte @@ -0,0 +1,508 @@ + + + + {t.title} | Contacts + + +
+ +
+ + +

+ {t.title} +

+

+ {t.subtitle} +

+
+ + +
+ +
+ +
+
+ + +
+ {#each sections as section (section.id)} + + {/each} +
+ + +
+ + {#if activeSection === 'faq'} +
+ {#each filteredFaqs as faq (faq.id)} +
+ + + {#if expandedFaqId === faq.id} +
+ {faq.answer} +
+ {/if} +
+ {/each} + + {#if filteredFaqs.length === 0} +
+ +

+ {$locale === 'de' ? 'Keine Ergebnisse gefunden' : 'No results found'} +

+
+ {/if} +
+ {/if} + + + {#if activeSection === 'features'} +
+ {#each features as feature} +
+
+ + {feature.icon} + +

+ {feature.title} +

+
+ +

+ {feature.description} +

+ +
    + {#each feature.highlights as highlight} +
  • + + + + {highlight} +
  • + {/each} +
+
+ {/each} +
+ {/if} + + + {#if activeSection === 'shortcuts'} +
+
+ {#each shortcuts as shortcut, i} +
+ {shortcut.action} + + {shortcut.shortcut} + +
+ {/each} +
+
+ + +
+ +

+ {$locale === 'de' + ? 'Tipp: Drücke Cmd/Ctrl + K, um jederzeit schnell zur Suche zu gelangen.' + : 'Tip: Press Cmd/Ctrl + K to quickly access search anytime.'} +

+
+ {/if} + + + {#if activeSection === 'contact'} +
+ +
+
+
+ +
+
+

{t.contactTitle}

+

{t.contactDescription}

+
+
+
+ + +
+ +
+ +
+
+

+ {t.email} +

+

support@manacore.app

+
+ +
+ +
+
+ +
+
+

+ {$locale === 'de' ? 'Antwortzeit' : 'Response Time'} +

+

+ {t.responseTime} +

+
+
+
+ + + +
+ {/if} +
+
diff --git a/apps/contacts/apps/web/src/routes/(app)/import/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/import/+page.svelte deleted file mode 100644 index 3fb8a9128..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/import/+page.svelte +++ /dev/null @@ -1,283 +0,0 @@ - - - - {$_('import.title')} - Contacts - - -
- -
-
-

{$_('import.title')}

-

{$_('import.subtitle')}

-
- - {$_('common.back')} - -
- - -
- - -
- - - {#if error && activeTab === 'file'} -
- {error} -
- {/if} - - - {#if activeTab === 'file'} - - {#if step === 'upload'} -
- {#if isLoading} -
-
-

{$_('import.processing')}

-
- {:else} - - -
- -
- {/if} -
- {/if} - - - {#if step === 'preview' && preview} - - {/if} - - - {#if step === 'result' && result} -
-
- - - -
- -
-

{$_('import.result.title')}

-

{$_('import.result.subtitle')}

-
- -
-
-
{result.imported}
-
{$_('import.result.imported')}
-
-
-
{result.merged}
-
{$_('import.result.merged')}
-
-
-
{result.skipped}
-
{$_('import.result.skipped')}
-
-
- - {#if result.errors.length > 0} -
-

{$_('import.result.errors')}

-
    - {#each result.errors as err} -
  • {err.contactName}: {err.error}
  • - {/each} -
-
- {/if} - -
- - -
-
- {/if} - {/if} - - - {#if activeTab === 'google'} - - {/if} -
diff --git a/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte new file mode 100644 index 000000000..0508d3e8f --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/network/+page.svelte @@ -0,0 +1,305 @@ + + + + Netzwerk - Contacts + + +
+ +
+ +
+ + + {#if networkStore.error} + + {/if} + + +
+ {#if networkStore.loading} + + {:else} + + {/if} +
+ + + {#if networkStore.selectedNodeId} + + {/if} +
+ + 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 6a9f4fc96..33795c905 100644 --- a/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/settings/+page.svelte @@ -1,17 +1,98 @@ @@ -19,8 +100,476 @@ + + + {#snippet icon()} + + + + {/snippet} + + + + {#snippet icon()} + + + + {/snippet} + + + + {#snippet icon()} + + + + {/snippet} + + Aktiv + + + + + - + + + + + {#snippet icon()} + + + + {/snippet} + + + + contactsSettings.set('defaultView', v as ContactView)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('sortBy', v as ContactSortBy)} + > + {#snippet icon()} + + + + {/snippet} + + + + contactsSettings.set('sortOrder', v as ContactSortOrder)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('showPhotos', v)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('showCompany', v)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('contactsPerPage', v ?? 50)} + min={10} + max={200} + border={false} + > + {#snippet icon()} + + + + {/snippet} + + + + + + + {#snippet icon()} + + + + {/snippet} + + + + contactsSettings.set('nameFormat', v as 'first-last' | 'last-first')} + > + {#snippet icon()} + + + + {/snippet} + + + + contactsSettings.set('dateFormat', v as DateFormat)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('showBirthdayReminders', v)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('birthdayReminderDays', v ?? 7)} + min={1} + max={30} + border={false} + > + {#snippet icon()} + + + + {/snippet} + + + + + + + {#snippet icon()} + + + + {/snippet} + + + + {#snippet icon()} + + + + {/snippet} + + + + {#snippet icon()} + + + + {/snippet} + + + + {#snippet icon()} + + + + {/snippet} + + + + + + + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('autoDetectDuplicates', v)} + > + {#snippet icon()} + + + + {/snippet} + + + + contactsSettings.set('duplicateSensitivity', v as 'strict' | 'normal' | 'loose')} + border={false} + > + {#snippet icon()} + + + + {/snippet} + + + + + + + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('privacyMode', v)} + > + {#snippet icon()} + + + + {/snippet} + + + contactsSettings.set('confirmBeforeSharing', v)} + > + {#snippet icon()} + + + + {/snippet} + + + userSettings.updateGeneral({ confirmOnDelete: v })} + border={false} + > + {#snippet icon()} + + + + {/snippet} + + + @@ -37,8 +586,58 @@ + {#snippet icon()} + + + + {/snippet} 1.0.0 + + + + + {#snippet icon()} + + + + {/snippet} + + + + {#snippet icon()} + + + + {/snippet} + + diff --git a/apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte new file mode 100644 index 000000000..7368fc5e5 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte @@ -0,0 +1,332 @@ + + + + {$_('tags.title')} - Contacts + + +
+ +
+ + + +

{$_('tags.title')}

+ +
+ + +
+ + +
+ + {#if error} + + {/if} + + + openEditModal(tag as ContactTag)} + onDelete={handleDeleteFromList} + emptyMessage={searchQuery ? $_('tags.noResults') : $_('tags.noTags')} + emptyDescription={searchQuery + ? $_('tags.noResultsFor', { values: { query: searchQuery } }) + : $_('tags.createFirst')} + /> + + {#if !loading && tags.length > 0} +

+ {tags.length} + {tags.length === 1 ? $_('tags.tagSingular') : $_('tags.tagPlural')} +

+ {/if} + + {#if !loading && tags.length === 0 && !searchQuery} +
+ +
+ {/if} +
+ + + + + diff --git a/apps/contacts/apps/web/src/routes/(app)/themes/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/themes/+page.svelte new file mode 100644 index 000000000..68bc36719 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/themes/+page.svelte @@ -0,0 +1,25 @@ + + + + Themes | Contacts + + + theme.setVariant(v)} + showModeSelector={true} + currentMode={theme.mode} + onModeChange={(m) => theme.setMode(m)} + showBackButton={true} + onBack={() => goto('/')} + showCustomThemes={true} + {customThemesStore} + onCreateTheme={() => goto('/themes/editor')} + onEditTheme={(t) => goto(`/themes/editor?id=${t.id}`)} + onCommunityThemes={() => goto('/themes/community')} +/> diff --git a/apps/contacts/apps/web/src/routes/(app)/themes/community/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/themes/community/+page.svelte new file mode 100644 index 000000000..7f4582f95 --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/themes/community/+page.svelte @@ -0,0 +1,29 @@ + + + + Community Themes | Contacts + + + goto('/themes')} + onSelectTheme={(t) => { + // Could open a detail modal here + console.log('Selected theme:', t); + }} +/> diff --git a/apps/contacts/apps/web/src/routes/(app)/themes/editor/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/themes/editor/+page.svelte new file mode 100644 index 000000000..83fb8653b --- /dev/null +++ b/apps/contacts/apps/web/src/routes/(app)/themes/editor/+page.svelte @@ -0,0 +1,75 @@ + + + + {themeId ? 'Theme bearbeiten' : 'Neues Theme'} | Contacts + + + goto('/themes')} + onSave={handleSave} + onPublish={handlePublish} +/> diff --git a/apps/contacts/apps/web/src/routes/+layout.svelte b/apps/contacts/apps/web/src/routes/+layout.svelte index 5604dd4c8..27f8460a2 100644 --- a/apps/contacts/apps/web/src/routes/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/+layout.svelte @@ -1,15 +1,82 @@ -{#if loading} -
-
-
-

Laden...

-
-
+{#if !appReady} + {:else}
{@render children()} diff --git a/apps/manacore/apps/web/src/app.css b/apps/manacore/apps/web/src/app.css index c1ecbea8d..152f31ace 100644 --- a/apps/manacore/apps/web/src/app.css +++ b/apps/manacore/apps/web/src/app.css @@ -6,6 +6,8 @@ @source "../../../../packages/shared-auth-ui/src"; @source "../../../../packages/shared-branding/src"; @source "../../../../packages/shared-theme-ui/src"; +@source "../../../../packages/shared-theme-ui/src/components"; +@source "../../../../packages/shared-theme-ui/src/pages"; @source "../../../../packages/shared-subscription-ui/src"; @layer base { diff --git a/apps/manacore/apps/web/src/hooks.server.ts b/apps/manacore/apps/web/src/hooks.server.ts index 1a092b58f..21b618758 100644 --- a/apps/manacore/apps/web/src/hooks.server.ts +++ b/apps/manacore/apps/web/src/hooks.server.ts @@ -1,32 +1,36 @@ import type { Handle } from '@sveltejs/kit'; -import { env } from '$env/dynamic/private'; /** * Server hooks for ManaCore web app * * Injects runtime environment variables into the HTML for client-side access. - * Uses $env/dynamic/private to read environment variables at RUNTIME (not build time), - * which is necessary for Docker containers that set env vars at runtime. + * This is necessary because SvelteKit's $env/static/public bakes values at build time, + * but Docker containers need runtime configuration. */ -export const handle: Handle = async ({ event, resolve }) => { - // Get client-side URLs from environment at RUNTIME (not build time) - const authUrlClient = env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || env.PUBLIC_MANA_CORE_AUTH_URL || ''; - const todoApiUrlClient = env.PUBLIC_TODO_API_URL_CLIENT || env.PUBLIC_TODO_API_URL || ''; - const calendarApiUrlClient = - env.PUBLIC_CALENDAR_API_URL_CLIENT || env.PUBLIC_CALENDAR_API_URL || ''; - const clockApiUrlClient = env.PUBLIC_CLOCK_API_URL_CLIENT || env.PUBLIC_CLOCK_API_URL || ''; - const contactsApiUrlClient = - env.PUBLIC_CONTACTS_API_URL_CLIENT || env.PUBLIC_CONTACTS_API_URL || ''; +// Auth URL +const PUBLIC_MANA_CORE_AUTH_URL_CLIENT = + process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || ''; +// Backend URLs for dashboard widgets +const PUBLIC_TODO_API_URL_CLIENT = + process.env.PUBLIC_TODO_API_URL_CLIENT || process.env.PUBLIC_TODO_API_URL || ''; +const PUBLIC_CALENDAR_API_URL_CLIENT = + process.env.PUBLIC_CALENDAR_API_URL_CLIENT || process.env.PUBLIC_CALENDAR_API_URL || ''; +const PUBLIC_CLOCK_API_URL_CLIENT = + process.env.PUBLIC_CLOCK_API_URL_CLIENT || process.env.PUBLIC_CLOCK_API_URL || ''; +const PUBLIC_CONTACTS_API_URL_CLIENT = + process.env.PUBLIC_CONTACTS_API_URL_CLIENT || process.env.PUBLIC_CONTACTS_API_URL || ''; + +export const handle: Handle = async ({ event, resolve }) => { return resolve(event, { transformPageChunk: ({ html }) => { const envScript = ``; return html.replace('', `${envScript}`); }, diff --git a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte index 0aa148f23..1676bda5a 100644 --- a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte @@ -6,7 +6,12 @@ import { locale } from 'svelte-i18n'; import { PillNavigation } from '@manacore/shared-ui'; import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui'; - 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 { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { setLocale, supportedLocales } from '$lib/i18n'; import { theme } from '$lib/stores/theme'; @@ -30,9 +35,19 @@ // Get theme state 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.map((variant) => ({ + ...visibleThemes.map((variant) => ({ id: variant, label: THEME_DEFINITIONS[variant].label, icon: THEME_DEFINITIONS[variant].icon, diff --git a/apps/manadeck/apps/web/src/app.css b/apps/manadeck/apps/web/src/app.css index f6ad4cf40..56ff68272 100644 --- a/apps/manadeck/apps/web/src/app.css +++ b/apps/manadeck/apps/web/src/app.css @@ -6,4 +6,6 @@ @source "../../../../packages/shared-auth-ui/src"; @source "../../../../packages/shared-branding/src"; @source "../../../../packages/shared-theme-ui/src"; +@source "../../../../packages/shared-theme-ui/src/components"; +@source "../../../../packages/shared-theme-ui/src/pages"; @source "../../../../packages/shared-subscription-ui/src"; diff --git a/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte b/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte index b51e1351f..c4f82f189 100644 --- a/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manadeck/apps/web/src/routes/(app)/+layout.svelte @@ -12,7 +12,12 @@ } from '$lib/stores/navigation'; import { PillNavigation } from '@manacore/shared-ui'; import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui'; - 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 { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; @@ -35,9 +40,19 @@ { href: '/progress', label: 'Progress', icon: 'chart' }, ]; + // 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.map((variant) => ({ + ...visibleThemes.map((variant) => ({ id: variant, label: THEME_DEFINITIONS[variant].label, icon: THEME_DEFINITIONS[variant].icon, diff --git a/apps/picture/apps/web/src/app.css b/apps/picture/apps/web/src/app.css index 3463ef16a..eb0a6229e 100644 --- a/apps/picture/apps/web/src/app.css +++ b/apps/picture/apps/web/src/app.css @@ -8,5 +8,7 @@ @source '../../../../packages/shared-auth-ui/src'; @source '../../../../packages/shared-branding/src'; @source '../../../../packages/shared-theme-ui/src'; +@source '../../../../packages/shared-theme-ui/src/components'; +@source '../../../../packages/shared-theme-ui/src/pages'; @source '../../../../packages/shared-subscription-ui/src'; @source '../../../../packages/shared-i18n/src'; diff --git a/apps/picture/apps/web/src/routes/app/+layout.svelte b/apps/picture/apps/web/src/routes/app/+layout.svelte index adf04ab18..99db8a558 100644 --- a/apps/picture/apps/web/src/routes/app/+layout.svelte +++ b/apps/picture/apps/web/src/routes/app/+layout.svelte @@ -6,7 +6,12 @@ import { locale } from 'svelte-i18n'; import { PillNavigation } from '@manacore/shared-ui'; import type { PillNavItem, PillNavElement, PillDropdownItem } from '@manacore/shared-ui'; - 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 { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; @@ -106,9 +111,19 @@ { id: 'gridSmall', icon: 'gridSmall', title: 'Klein (3)' }, ]; + // 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.map((variant) => ({ + ...visibleThemes.map((variant) => ({ id: variant, label: THEME_DEFINITIONS[variant].label, icon: THEME_DEFINITIONS[variant].icon, diff --git a/apps/todo/apps/backend/src/app.module.ts b/apps/todo/apps/backend/src/app.module.ts index 5c03f584b..3823bdd3f 100644 --- a/apps/todo/apps/backend/src/app.module.ts +++ b/apps/todo/apps/backend/src/app.module.ts @@ -8,6 +8,7 @@ import { TaskModule } from './task/task.module'; import { LabelModule } from './label/label.module'; import { ReminderModule } from './reminder/reminder.module'; import { KanbanModule } from './kanban/kanban.module'; +import { NetworkModule } from './network/network.module'; @Module({ imports: [ @@ -23,6 +24,7 @@ import { KanbanModule } from './kanban/kanban.module'; LabelModule, ReminderModule, KanbanModule, + NetworkModule, ], }) export class AppModule {} diff --git a/apps/todo/apps/backend/src/network/network.controller.ts b/apps/todo/apps/backend/src/network/network.controller.ts new file mode 100644 index 000000000..0ae89347c --- /dev/null +++ b/apps/todo/apps/backend/src/network/network.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { NetworkService } from './network.service'; + +@Controller('api/v1/network') +@UseGuards(JwtAuthGuard) +export class NetworkController { + constructor(private readonly networkService: NetworkService) {} + + @Get('graph') + async getGraph(@CurrentUser() user: CurrentUserData) { + return this.networkService.getGraph(user.userId); + } +} diff --git a/apps/todo/apps/backend/src/network/network.module.ts b/apps/todo/apps/backend/src/network/network.module.ts new file mode 100644 index 000000000..719a19f0d --- /dev/null +++ b/apps/todo/apps/backend/src/network/network.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { NetworkController } from './network.controller'; +import { NetworkService } from './network.service'; + +@Module({ + controllers: [NetworkController], + providers: [NetworkService], + exports: [NetworkService], +}) +export class NetworkModule {} diff --git a/apps/todo/apps/backend/src/network/network.service.ts b/apps/todo/apps/backend/src/network/network.service.ts new file mode 100644 index 000000000..8b5d26524 --- /dev/null +++ b/apps/todo/apps/backend/src/network/network.service.ts @@ -0,0 +1,136 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { tasks, labels, taskLabels, projects } from '../db/schema'; + +export interface NetworkNode { + id: string; + name: string; + photoUrl: string | null; + company: string | null; // Project name as subtitle + isFavorite: boolean; + tags: { id: string; name: string; color: string | null }[]; + connectionCount: number; +} + +export interface NetworkLink { + source: string; + target: string; + type: 'tag'; + strength: number; + sharedTags: string[]; +} + +export interface NetworkGraphResponse { + nodes: NetworkNode[]; + links: NetworkLink[]; +} + +@Injectable() +export class NetworkService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + /** + * Build a network graph of tasks connected by shared labels + */ + async getGraph(userId: string): Promise { + // 1. Get all tasks for user + const userTasks = await this.db + .select({ + task: tasks, + }) + .from(tasks) + .where(eq(tasks.userId, userId)); + + // 2. Get all projects for this user (for project names) + const userProjects = await this.db + .select({ + id: projects.id, + name: projects.name, + }) + .from(projects) + .where(eq(projects.userId, userId)); + + const projectMap = new Map(userProjects.map((p) => [p.id, p.name])); + + // 3. Get labels for each task + const taskLabelsMap = new Map(); + + for (const { task } of userTasks) { + const taskLabelRows = await this.db + .select({ + id: labels.id, + name: labels.name, + color: labels.color, + }) + .from(taskLabels) + .innerJoin(labels, eq(taskLabels.labelId, labels.id)) + .where(eq(taskLabels.taskId, task.id)); + + taskLabelsMap.set(task.id, taskLabelRows); + } + + // 4. Filter tasks that have at least one label + const tasksWithLabels = userTasks.filter((t) => { + const lbls = taskLabelsMap.get(t.task.id) || []; + return lbls.length > 0; + }); + + // 5. Build nodes + const nodes: NetworkNode[] = tasksWithLabels.map(({ task }) => { + const lbls = taskLabelsMap.get(task.id) || []; + const projectName = task.projectId ? projectMap.get(task.projectId) || null : null; + return { + id: task.id, + name: task.title, + photoUrl: null, // Tasks don't have photos + company: projectName, // Use project name as subtitle + isFavorite: false, + tags: lbls, + connectionCount: 0, // Will be calculated below + }; + }); + + // 6. Build links based on shared labels + const links: NetworkLink[] = []; + const connectionCounts = new Map(); + + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const node1 = nodes[i]; + const node2 = nodes[j]; + + // Find shared labels + const sharedTags = node1.tags + .filter((t1) => node2.tags.some((t2) => t2.id === t1.id)) + .map((t) => t.name); + + if (sharedTags.length > 0) { + // Calculate strength based on number of shared labels + const maxTags = Math.max(node1.tags.length, node2.tags.length); + const strength = Math.round((sharedTags.length / maxTags) * 100); + + links.push({ + source: node1.id, + target: node2.id, + type: 'tag', + strength, + sharedTags, + }); + + // Update connection counts + connectionCounts.set(node1.id, (connectionCounts.get(node1.id) || 0) + 1); + connectionCounts.set(node2.id, (connectionCounts.get(node2.id) || 0) + 1); + } + } + } + + // 7. Update connection counts in nodes + for (const node of nodes) { + node.connectionCount = connectionCounts.get(node.id) || 0; + } + + return { nodes, links }; + } +} diff --git a/apps/todo/apps/web/package.json b/apps/todo/apps/web/package.json index e6718fd57..4d6119404 100644 --- a/apps/todo/apps/web/package.json +++ b/apps/todo/apps/web/package.json @@ -17,6 +17,7 @@ "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.1.7", + "@types/d3-force": "^3.0.0", "@types/node": "^20.0.0", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", @@ -29,6 +30,7 @@ }, "dependencies": { "@manacore/shared-auth": "workspace:*", + "@manacore/shared-tags": "workspace:*", "@manacore/shared-auth-ui": "workspace:*", "@manacore/shared-branding": "workspace:*", "@manacore/shared-feedback-service": "workspace:*", @@ -42,6 +44,7 @@ "@manacore/shared-theme-ui": "workspace:*", "@manacore/shared-ui": "workspace:*", "@todo/shared": "workspace:*", + "d3-force": "^3.0.0", "date-fns": "^4.1.0", "lucide-svelte": "^0.556.0", "svelte-dnd-action": "^0.9.68", diff --git a/apps/todo/apps/web/src/app.css b/apps/todo/apps/web/src/app.css index 957700956..1165d9637 100644 --- a/apps/todo/apps/web/src/app.css +++ b/apps/todo/apps/web/src/app.css @@ -5,6 +5,8 @@ @source "../../../packages/shared/src"; @source "../../../../../packages/shared-ui/src"; @source "../../../../../packages/shared-theme-ui/src"; +@source "../../../../../packages/shared-theme-ui/src/components"; +@source "../../../../../packages/shared-theme-ui/src/pages"; :root { /* Todo App - Purple/Violet Theme */ diff --git a/apps/todo/apps/web/src/hooks.server.ts b/apps/todo/apps/web/src/hooks.server.ts index e94acafb9..6d7a5089d 100644 --- a/apps/todo/apps/web/src/hooks.server.ts +++ b/apps/todo/apps/web/src/hooks.server.ts @@ -5,21 +5,21 @@ */ import type { Handle } from '@sveltejs/kit'; -import { env } from '$env/dynamic/private'; + +// Get client-side URLs from environment (Docker runtime) +const PUBLIC_MANA_CORE_AUTH_URL_CLIENT = + process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || ''; +const PUBLIC_BACKEND_URL_CLIENT = + process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || ''; export const handle: Handle = async ({ event, resolve }) => { - // Get client-side URLs from environment at RUNTIME (not build time) - // Use $env/dynamic/private to read actual runtime environment variables - const authUrlClient = env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || env.PUBLIC_MANA_CORE_AUTH_URL || ''; - const backendUrlClient = env.PUBLIC_BACKEND_URL_CLIENT || env.PUBLIC_BACKEND_URL || ''; - return resolve(event, { transformPageChunk: ({ html }) => { // Inject runtime environment variables into the HTML // These will be available on window.__PUBLIC_*__ for client-side code const envScript = ``; return html.replace('', `${envScript}`); }, diff --git a/apps/todo/apps/web/src/lib/api/labels.ts b/apps/todo/apps/web/src/lib/api/labels.ts index 4eeb3d04c..c19c75373 100644 --- a/apps/todo/apps/web/src/lib/api/labels.ts +++ b/apps/todo/apps/web/src/lib/api/labels.ts @@ -1,39 +1,76 @@ -import { apiClient } from './client'; -import type { Label } from '@todo/shared'; +/** + * Labels API - Uses central Tags API from mana-core-auth + * + * This module wraps the central Tags API to provide backward-compatible + * "labels" interface for the Todo app. Tags and Labels are now unified + * across all Manacore apps. + */ -interface CreateLabelDto { - name: string; - color?: string; +import { browser } from '$app/environment'; +import { + createTagsClient, + type Tag, + type CreateTagInput, + type UpdateTagInput, +} from '@manacore/shared-tags'; +import { authStore } from '$lib/stores/auth.svelte'; + +// Re-export Tag as Label for backward compatibility +export type Label = Tag; + +// 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'; } -interface UpdateLabelDto { - name?: string; - color?: string; -} +// Lazy-initialized client +let _tagsClient: ReturnType | null = null; -interface LabelsResponse { - labels: Label[]; -} - -interface LabelResponse { - label: Label; +function getTagsClient() { + if (!browser) return null; + if (!_tagsClient) { + _tagsClient = createTagsClient({ + authUrl: getAuthUrl(), + getToken: async () => { + const token = await authStore.getAccessToken(); + return token || ''; + }, + }); + } + return _tagsClient; } export async function getLabels(): Promise { - const response = await apiClient.get('/api/v1/labels'); - return response.labels; + const client = getTagsClient(); + if (!client) return []; + return client.getAll(); } -export async function createLabel(data: CreateLabelDto): Promise