From 2b3f92ff36df2c4ed482ef5270bc5a48fcdb20f8 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 9 Dec 2025 20:29:38 +0100 Subject: [PATCH] feat(contacts): add interactive network graph visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NetworkModule backend with tag-based relationship detection - Create D3-force powered network graph component with zoom/pan/drag - Implement network store with Svelte 5 runes for state management - Add floating controls for search, filter by tag/company, and zoom - Full-screen graph layout with sidebar for selected contact details - Contacts are connected when they share common tags 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/contacts/apps/backend/src/app.module.ts | 2 + .../backend/src/network/network.controller.ts | 24 + .../backend/src/network/network.module.ts | 10 + .../backend/src/network/network.service.ts | 151 +++++ apps/contacts/apps/web/package.json | 10 + apps/contacts/apps/web/src/lib/api/network.ts | 74 +++ .../components/network/NetworkControls.svelte | 370 +++++++++++++ .../components/network/NetworkGraph.svelte | 492 +++++++++++++++++ .../apps/web/src/lib/stores/network.svelte.ts | 434 +++++++++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 57 +- .../web/src/routes/(app)/network/+page.svelte | 522 ++++++++++++++++++ 11 files changed, 2142 insertions(+), 4 deletions(-) create mode 100644 apps/contacts/apps/backend/src/network/network.controller.ts create mode 100644 apps/contacts/apps/backend/src/network/network.module.ts create mode 100644 apps/contacts/apps/backend/src/network/network.service.ts create mode 100644 apps/contacts/apps/web/src/lib/api/network.ts create mode 100644 apps/contacts/apps/web/src/lib/components/network/NetworkControls.svelte create mode 100644 apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte create mode 100644 apps/contacts/apps/web/src/lib/stores/network.svelte.ts create mode 100644 apps/contacts/apps/web/src/routes/(app)/network/+page.svelte diff --git a/apps/contacts/apps/backend/src/app.module.ts b/apps/contacts/apps/backend/src/app.module.ts index 9edecaeda..778b8ae18 100644 --- a/apps/contacts/apps/backend/src/app.module.ts +++ b/apps/contacts/apps/backend/src/app.module.ts @@ -12,6 +12,7 @@ import { GoogleModule } from './google/google.module'; import { DuplicatesModule } from './duplicates/duplicates.module'; import { PhotoModule } from './photo/photo.module'; import { BatchModule } from './batch/batch.module'; +import { NetworkModule } from './network/network.module'; @Module({ imports: [ @@ -31,6 +32,7 @@ import { BatchModule } from './batch/batch.module'; DuplicatesModule, PhotoModule, BatchModule, + NetworkModule, ], }) export class AppModule {} diff --git a/apps/contacts/apps/backend/src/network/network.controller.ts b/apps/contacts/apps/backend/src/network/network.controller.ts new file mode 100644 index 000000000..a9a596fc2 --- /dev/null +++ b/apps/contacts/apps/backend/src/network/network.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, UseGuards, Query } from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { NetworkService } from './network.service'; +import { IsString, IsOptional, IsIn } from 'class-validator'; + +class NetworkQueryDto { + @IsString() + @IsOptional() + @IsIn(['tags']) + type?: 'tags'; +} + +@Controller('network') +@UseGuards(JwtAuthGuard) +export class NetworkController { + constructor(private readonly networkService: NetworkService) {} + + @Get('graph') + async getGraph(@CurrentUser() user: CurrentUserData, @Query() query: NetworkQueryDto) { + // Currently only tag-based graph is supported (MVP) + const graph = await this.networkService.getTagBasedGraph(user.userId); + return graph; + } +} diff --git a/apps/contacts/apps/backend/src/network/network.module.ts b/apps/contacts/apps/backend/src/network/network.module.ts new file mode 100644 index 000000000..719a19f0d --- /dev/null +++ b/apps/contacts/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/contacts/apps/backend/src/network/network.service.ts b/apps/contacts/apps/backend/src/network/network.service.ts new file mode 100644 index 000000000..ac4184977 --- /dev/null +++ b/apps/contacts/apps/backend/src/network/network.service.ts @@ -0,0 +1,151 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { eq, sql } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { contacts, contactTags, contactToTags } from '../db/schema'; + +export interface NetworkNode { + id: string; + name: string; + photoUrl: string | null; + company: string | null; + 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) {} + + async getTagBasedGraph(userId: string): Promise { + // 1. Get all contacts for the user (excluding archived) + const userContacts = await this.db + .select({ + id: contacts.id, + firstName: contacts.firstName, + lastName: contacts.lastName, + displayName: contacts.displayName, + photoUrl: contacts.photoUrl, + company: contacts.company, + isFavorite: contacts.isFavorite, + }) + .from(contacts) + .where(eq(contacts.userId, userId)); + + if (userContacts.length === 0) { + return { nodes: [], links: [] }; + } + + // 2. Get all tags for the user + const userTags = await this.db.select().from(contactTags).where(eq(contactTags.userId, userId)); + + const tagMap = new Map(userTags.map((t) => [t.id, t])); + + // 3. Get all contact-tag associations + const contactTagAssociations = await this.db + .select({ + contactId: contactToTags.contactId, + tagId: contactToTags.tagId, + }) + .from(contactToTags) + .innerJoin(contacts, eq(contactToTags.contactId, contacts.id)) + .where(eq(contacts.userId, userId)); + + // 4. Build contact -> tags mapping + const contactTagsMap = new Map(); + for (const assoc of contactTagAssociations) { + const existing = contactTagsMap.get(assoc.contactId) || []; + existing.push(assoc.tagId); + contactTagsMap.set(assoc.contactId, existing); + } + + // 5. Build nodes + const nodes: NetworkNode[] = userContacts.map((contact) => { + const tagIds = contactTagsMap.get(contact.id) || []; + const tags = tagIds + .map((tagId) => { + const tag = tagMap.get(tagId); + return tag ? { id: tag.id, name: tag.name, color: tag.color } : null; + }) + .filter((t): t is { id: string; name: string; color: string | null } => t !== null); + + return { + id: contact.id, + name: this.getDisplayName(contact), + photoUrl: contact.photoUrl, + company: contact.company, + isFavorite: contact.isFavorite ?? false, + tags, + connectionCount: 0, // Will be calculated after links + }; + }); + + // 6. Build links based on shared tags + 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 nodeA = nodes[i]; + const nodeB = nodes[j]; + + const tagsA = new Set(nodeA.tags.map((t) => t.id)); + const tagsB = new Set(nodeB.tags.map((t) => t.id)); + + const sharedTagIds = [...tagsA].filter((tagId) => tagsB.has(tagId)); + + if (sharedTagIds.length > 0) { + const sharedTagNames = sharedTagIds + .map((tagId) => tagMap.get(tagId)?.name) + .filter((name): name is string => !!name); + + // Strength based on number of shared tags (max 100) + const strength = Math.min(sharedTagIds.length * 25, 100); + + links.push({ + source: nodeA.id, + target: nodeB.id, + type: 'tag', + strength, + sharedTags: sharedTagNames, + }); + + // Count connections + connectionCounts.set(nodeA.id, (connectionCounts.get(nodeA.id) || 0) + 1); + connectionCounts.set(nodeB.id, (connectionCounts.get(nodeB.id) || 0) + 1); + } + } + } + + // 7. Update connection counts on nodes + for (const node of nodes) { + node.connectionCount = connectionCounts.get(node.id) || 0; + } + + return { nodes, links }; + } + + private getDisplayName(contact: { + firstName: string | null; + lastName: string | null; + displayName: string | null; + }): string { + if (contact.displayName) return contact.displayName; + const parts = [contact.firstName, contact.lastName].filter(Boolean); + return parts.length > 0 ? parts.join(' ') : 'Unbekannt'; + } +} diff --git a/apps/contacts/apps/web/package.json b/apps/contacts/apps/web/package.json index 1703446da..51d97079f 100644 --- a/apps/contacts/apps/web/package.json +++ b/apps/contacts/apps/web/package.json @@ -16,6 +16,9 @@ "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.1.7", + "@types/d3-force": "^3.0.10", + "@types/d3-selection": "^3.0.11", + "@types/d3-zoom": "^3.0.8", "@types/node": "^20.0.0", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", @@ -32,6 +35,9 @@ "@manacore/shared-branding": "workspace:*", "@manacore/shared-feedback-service": "workspace:*", "@manacore/shared-feedback-ui": "workspace:*", + "@manacore/shared-help-content": "workspace:*", + "@manacore/shared-help-types": "workspace:*", + "@manacore/shared-help-ui": "workspace:*", "@manacore/shared-i18n": "workspace:*", "@manacore/shared-icons": "workspace:*", "@manacore/shared-profile-ui": "workspace:*", @@ -41,6 +47,10 @@ "@manacore/shared-theme-ui": "workspace:*", "@manacore/shared-ui": "workspace:*", "@manacore/shared-utils": "workspace:*", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "lucide-svelte": "^0.556.0", "svelte-i18n": "^4.0.1" }, "type": "module" diff --git a/apps/contacts/apps/web/src/lib/api/network.ts b/apps/contacts/apps/web/src/lib/api/network.ts new file mode 100644 index 000000000..a7145170a --- /dev/null +++ b/apps/contacts/apps/web/src/lib/api/network.ts @@ -0,0 +1,74 @@ +import { authStore } from '$lib/stores/auth.svelte'; +import { API_BASE } from './config'; + +async function fetchWithAuth(url: string, options: RequestInit = {}) { + let token: string | null = null; + try { + token = await authStore.getAccessToken(); + console.log('[Network API] Got token:', token ? 'present' : 'missing'); + } catch (e) { + console.error('[Network API] Error getting token:', e); + } + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const fullUrl = `${API_BASE}${url}`; + console.log('[Network API] Fetching:', fullUrl); + + const response = await fetch(fullUrl, { + ...options, + headers, + }); + + console.log('[Network API] Response status:', response.status); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[Network API] Error response:', errorText); + let error: { message?: string } = { message: 'Request failed' }; + try { + error = JSON.parse(errorText); + } catch { + error = { message: errorText || 'Request failed' }; + } + throw new Error(error.message || 'Request failed'); + } + + return response.json(); +} + +export interface NetworkNode { + id: string; + name: string; + photoUrl: string | null; + company: string | null; + 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[]; +} + +export const networkApi = { + async getGraph(): Promise { + return fetchWithAuth('/network/graph'); + }, +}; diff --git a/apps/contacts/apps/web/src/lib/components/network/NetworkControls.svelte b/apps/contacts/apps/web/src/lib/components/network/NetworkControls.svelte new file mode 100644 index 000000000..619059660 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/network/NetworkControls.svelte @@ -0,0 +1,370 @@ + + +
+ +
+ + + {#if searchInput} + + {/if} +
+ + + + + +
+ + + +
+ + +
+ + {networkStore.nodes.length} Kontakte + + + + {networkStore.links.length} Verbindungen + +
+
+ + +{#if showFilters} +
+
+ +
+ + +
+ + +
+ + +
+ + + {#if hasActiveFilters} + + {/if} +
+
+{/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 new file mode 100644 index 000000000..af1ff0d97 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/components/network/NetworkGraph.svelte @@ -0,0 +1,492 @@ + + +
+ + + + + {#each graphLinks as link} + {@const coords = getLinkCoords(link)} + {@const sourceId = typeof link.source === 'string' ? link.source : link.source.id} + {@const targetId = typeof link.target === 'string' ? link.target : link.target.id} + {@const isHighlighted = + networkStore.selectedNodeId && + (sourceId === networkStore.selectedNodeId || targetId === networkStore.selectedNodeId)} + + {link.sharedTags.join(', ')} + + {/each} + + + + + {#each graphNodes as node (node.id)} + {@const isSelected = node.id === networkStore.selectedNodeId} + {@const isConnected = isConnectedToSelected(node.id, graphLinks)} + {@const isDimmed = networkStore.selectedNodeId && !isConnected} + handleDragStart(e, node)} + onclick={() => handleNodeClick(node)} + ondblclick={() => handleNodeDoubleClick(node)} + role="button" + tabindex="0" + aria-label={node.name} + > + + + + + {#if node.photoUrl} + + + + + {:else} + + {getInitials(node.name)} + + {/if} + + + {#if node.isFavorite} + + + ⭐ + + {/if} + + + {#if node.connectionCount > 0} + + + {node.connectionCount} + + {/if} + + + + {node.name} + + + + {#if node.company} + + {node.company} + + {/if} + + {/each} + + + + + + {#if graphNodes.length === 0 && !networkStore.loading} +
+
🔗
+

Keine Verbindungen gefunden

+

+ Kontakte werden verbunden, wenn sie gemeinsame Tags haben. Füge Tags zu deinen Kontakten + hinzu, um das Netzwerk zu sehen. +

+
+ {/if} +
+ + 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..58bd3df69 --- /dev/null +++ b/apps/contacts/apps/web/src/lib/stores/network.svelte.ts @@ -0,0 +1,434 @@ +/** + * 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, + type SimulationNodeDatum, + type SimulationLinkDatum, +} from 'd3-force'; + +// Extended types for D3 simulation +export interface SimulationNode extends NetworkNode, SimulationNodeDatum { + x?: number; + y?: number; + vx?: number; + vy?: number; + fx?: number | null; + fy?: number | null; +} + +export interface SimulationLink extends SimulationLinkDatum { + type: 'tag'; + strength: number; + sharedTags: string[]; +} + +// 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 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.company?.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 + if (filterCompany) { + result = result.filter((node) => node.company === 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; + return filteredNodeIds.has(sourceId) && filteredNodeIds.has(targetId); + }); +}); + +// Get unique companies for filter dropdown +const uniqueCompanies = $derived.by(() => { + const companies = new Set(); + for (const node of nodes) { + if (node.company) { + companies.add(node.company); + } + } + 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 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 + nodes = response.nodes.map((node) => ({ + ...node, + 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; + }, + + /** + * Clear all filters + */ + clearFilters() { + searchQuery = ''; + filterTagId = null; + filterCompany = null; + }, + + /** + * 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/routes/(app)/+layout.svelte b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte index 7f4da318b..aa5eb3db1 100644 --- a/apps/contacts/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/(app)/+layout.svelte @@ -3,8 +3,13 @@ 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'; @@ -17,8 +22,8 @@ import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; import ContactDetailModal from '$lib/components/ContactDetailModal.svelte'; - import SearchModal from '$lib/components/SearchModal.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'; @@ -152,6 +157,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) { @@ -238,7 +278,16 @@ {/if} - (searchModalOpen = false)} /> + (searchModalOpen = false)} + onSearch={handleCommandBarSearch} + onSelect={handleCommandBarSelect} + quickActions={commandBarQuickActions} + placeholder="Kontakt suchen..." + emptyText="Keine Kontakte gefunden" + searchingText="Suche..." + />