feat(contacts): add interactive network graph visualization

- 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 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-09 20:29:38 +01:00
parent 1dda437192
commit 2b3f92ff36
11 changed files with 2142 additions and 4 deletions

View file

@ -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 {}

View file

@ -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;
}
}

View file

@ -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 {}

View file

@ -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<NetworkGraphResponse> {
// 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<string, string[]>();
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<string, number>();
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';
}
}

View file

@ -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"

View file

@ -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<string, string>)['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<NetworkGraphResponse> {
return fetchWithAuth('/network/graph');
},
};

View file

@ -0,0 +1,370 @@
<script lang="ts">
import { networkStore } from '$lib/stores/network.svelte';
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X } from 'lucide-svelte';
interface Props {
onZoomIn: () => void;
onZoomOut: () => void;
onResetZoom: () => void;
}
let { onZoomIn, onZoomOut, onResetZoom }: Props = $props();
let searchInput = $state(networkStore.searchQuery);
let showFilters = $state(false);
function handleSearchInput(event: Event) {
const target = event.target as HTMLInputElement;
searchInput = target.value;
networkStore.setSearch(target.value);
}
function clearSearch() {
searchInput = '';
networkStore.setSearch('');
}
function handleTagChange(event: Event) {
const target = event.target as HTMLSelectElement;
networkStore.setFilterTag(target.value || null);
}
function handleCompanyChange(event: Event) {
const target = event.target as HTMLSelectElement;
networkStore.setFilterCompany(target.value || null);
}
function clearAllFilters() {
searchInput = '';
networkStore.clearFilters();
}
const hasActiveFilters = $derived(
networkStore.searchQuery || networkStore.filterTagId || networkStore.filterCompany
);
</script>
<div class="network-controls">
<!-- Search bar -->
<div class="search-container">
<Search size={18} class="search-icon" />
<input
type="text"
placeholder="Kontakt suchen..."
value={searchInput}
oninput={handleSearchInput}
class="search-input"
/>
{#if searchInput}
<button onclick={clearSearch} class="clear-btn" aria-label="Suche löschen">
<X size={16} />
</button>
{/if}
</div>
<!-- Filter toggle -->
<button
onclick={() => (showFilters = !showFilters)}
class="control-btn"
class:active={showFilters || hasActiveFilters}
aria-label="Filter anzeigen"
title="Filter"
>
<Filter size={18} />
{#if hasActiveFilters}
<span class="filter-badge"></span>
{/if}
</button>
<!-- Zoom controls -->
<div class="zoom-controls">
<button onclick={onZoomIn} class="control-btn" aria-label="Vergrößern" title="Vergrößern">
<ZoomIn size={18} />
</button>
<button onclick={onZoomOut} class="control-btn" aria-label="Verkleinern" title="Verkleinern">
<ZoomOut size={18} />
</button>
<button
onclick={onResetZoom}
class="control-btn"
aria-label="Ansicht zurücksetzen"
title="Zurücksetzen"
>
<RotateCcw size={18} />
</button>
</div>
<!-- Stats -->
<div class="stats">
<span class="stat">
{networkStore.nodes.length} Kontakte
</span>
<span class="stat-divider"></span>
<span class="stat">
{networkStore.links.length} Verbindungen
</span>
</div>
</div>
<!-- Filter panel -->
{#if showFilters}
<div class="filter-panel">
<div class="filter-row">
<!-- Tag filter -->
<div class="filter-group">
<label for="tag-filter" class="filter-label">Tag</label>
<select
id="tag-filter"
onchange={handleTagChange}
value={networkStore.filterTagId || ''}
class="filter-select"
>
<option value="">Alle Tags</option>
{#each networkStore.uniqueTags as tag}
<option value={tag.id}>
{tag.name}
</option>
{/each}
</select>
</div>
<!-- Company filter -->
<div class="filter-group">
<label for="company-filter" class="filter-label">Firma</label>
<select
id="company-filter"
onchange={handleCompanyChange}
value={networkStore.filterCompany || ''}
class="filter-select"
>
<option value="">Alle Firmen</option>
{#each networkStore.uniqueCompanies as company}
<option value={company}>
{company}
</option>
{/each}
</select>
</div>
<!-- Clear filters button -->
{#if hasActiveFilters}
<button onclick={clearAllFilters} class="clear-filters-btn">
<X size={14} />
Filter löschen
</button>
{/if}
</div>
</div>
{/if}
<style>
.network-controls {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
flex-wrap: wrap;
}
.search-container {
position: relative;
flex: 1;
min-width: 200px;
max-width: 300px;
}
.search-container :global(.search-icon) {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.5rem 2rem 0.5rem 2.5rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.search-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
}
.search-input::placeholder {
color: hsl(var(--muted-foreground));
}
.clear-btn {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
padding: 0.25rem;
background: none;
border: none;
color: hsl(var(--muted-foreground));
cursor: pointer;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted));
}
.control-btn {
position: relative;
padding: 0.5rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
color: hsl(var(--muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.control-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.control-btn.active {
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary));
color: hsl(var(--primary));
}
.filter-badge {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: hsl(var(--primary));
border-radius: 50%;
}
.zoom-controls {
display: flex;
gap: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid hsl(var(--border));
}
.stats {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.stat-divider {
opacity: 0.5;
}
/* Filter panel */
.filter-panel {
margin-top: 0.5rem;
padding: 0.75rem 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
}
.filter-row {
display: flex;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 150px;
}
.filter-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.filter-select {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: hsl(var(--primary));
}
.clear-filters-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.2);
border-radius: 0.5rem;
color: hsl(var(--destructive));
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.clear-filters-btn:hover {
background: hsl(var(--destructive) / 0.15);
}
@media (max-width: 640px) {
.network-controls {
flex-direction: column;
align-items: stretch;
}
.search-container {
max-width: none;
}
.zoom-controls {
padding-left: 0;
border-left: none;
justify-content: center;
}
.stats {
justify-content: center;
margin-left: 0;
}
}
</style>

View file

@ -0,0 +1,492 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import {
networkStore,
type SimulationNode,
type SimulationLink,
} from '$lib/stores/network.svelte';
import { zoom, zoomIdentity, type ZoomBehavior } from 'd3-zoom';
import { select } from 'd3-selection';
interface Props {
width?: number;
height?: number;
onNodeClick?: (node: SimulationNode) => void;
}
let { width = 800, height = 600, onNodeClick }: Props = $props();
let svgElement: SVGSVGElement;
let containerElement: HTMLDivElement;
let zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> | null = null;
let transform = $state({ x: 0, y: 0, k: 1 });
let draggedNode: SimulationNode | null = null;
let resizeObserver: ResizeObserver | null = null;
let containerWidth = $state(0);
let containerHeight = $state(0);
let hasInitialized = $state(false);
let initTimeoutId: ReturnType<typeof setTimeout> | null = null;
// Initialize simulation ONCE when nodes are loaded AND dimensions are stable
function tryInitialize() {
const nodeCount = networkStore.allNodes.length;
if (!hasInitialized && nodeCount > 0 && containerWidth > 100 && containerHeight > 100) {
console.log(
'[NetworkGraph] Initializing with dimensions:',
containerWidth,
'x',
containerHeight
);
hasInitialized = true;
networkStore.initSimulation(containerWidth, containerHeight);
}
}
// Try to initialize when nodes become available
$effect(() => {
const nodeCount = networkStore.allNodes.length;
if (nodeCount > 0 && containerWidth > 100 && containerHeight > 100) {
tryInitialize();
}
});
// Get nodes and links (these will update on each tick)
const graphNodes = $derived(networkStore.nodes);
const graphLinks = $derived(networkStore.links);
// Setup zoom behavior
$effect(() => {
if (svgElement) {
zoomBehavior = zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
transform = {
x: event.transform.x,
y: event.transform.y,
k: event.transform.k,
};
});
select(svgElement).call(zoomBehavior);
}
});
onMount(() => {
// Setup resize observer - wait for stable dimensions before initializing
if (containerElement) {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newWidth = entry.contentRect.width;
const newHeight = entry.contentRect.height;
if (newWidth > 100 && newHeight > 100) {
containerWidth = newWidth;
containerHeight = newHeight;
// Debounce initialization to wait for layout to stabilize
if (!hasInitialized) {
if (initTimeoutId) clearTimeout(initTimeoutId);
initTimeoutId = setTimeout(() => {
console.log(
'[NetworkGraph] Stable dimensions:',
containerWidth,
'x',
containerHeight
);
tryInitialize();
}, 100);
}
}
}
});
resizeObserver.observe(containerElement);
}
});
onDestroy(() => {
if (initTimeoutId) clearTimeout(initTimeoutId);
networkStore.reset();
resizeObserver?.disconnect();
});
function handleNodeClick(node: SimulationNode) {
networkStore.selectNode(node.id);
onNodeClick?.(node);
}
function handleNodeDoubleClick(node: SimulationNode) {
// Navigate to contact detail
goto(`/contacts/${node.id}`);
}
function handleDragStart(event: MouseEvent, node: SimulationNode) {
event.stopPropagation();
draggedNode = node;
networkStore.fixNode(node.id, node.x ?? 0, node.y ?? 0);
networkStore.reheatSimulation();
}
function handleDrag(event: MouseEvent) {
if (!draggedNode) return;
// Convert screen coordinates to graph coordinates
const x = (event.clientX - svgElement.getBoundingClientRect().left - transform.x) / transform.k;
const y = (event.clientY - svgElement.getBoundingClientRect().top - transform.y) / transform.k;
networkStore.fixNode(draggedNode.id, x, y);
}
function handleDragEnd() {
if (draggedNode) {
networkStore.releaseNode(draggedNode.id);
draggedNode = null;
}
}
function resetZoom() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(300).call(zoomBehavior.transform, zoomIdentity);
}
}
function zoomIn() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 1.3);
}
}
function zoomOut() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 0.7);
}
}
// Helper to get node initials
function getInitials(name: string): string {
const parts = name.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
// Helper to generate consistent color from string
function stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = hash % 360;
return `hsl(${hue}, 70%, 50%)`;
}
// Get link coordinates
function getLinkCoords(link: SimulationLink) {
const source = link.source as SimulationNode;
const target = link.target as SimulationNode;
return {
x1: source.x ?? 0,
y1: source.y ?? 0,
x2: target.x ?? 0,
y2: target.y ?? 0,
};
}
// Check if a node is connected to selected node
function isConnectedToSelected(nodeId: string, links: typeof graphLinks): boolean {
if (!networkStore.selectedNodeId) return false;
if (nodeId === networkStore.selectedNodeId) return true;
return links.some((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 === networkStore.selectedNodeId && targetId === nodeId) ||
(targetId === networkStore.selectedNodeId && sourceId === nodeId)
);
});
}
// Export zoom functions for parent component
export { resetZoom, zoomIn, zoomOut };
</script>
<div
bind:this={containerElement}
class="network-graph-container"
onmousemove={handleDrag}
onmouseup={handleDragEnd}
onmouseleave={handleDragEnd}
role="application"
aria-label="Kontakt-Netzwerk Graph"
>
<svg bind:this={svgElement} class="network-graph-svg" style="width: 100%; height: 100%;">
<g transform="translate({transform.x}, {transform.y}) scale({transform.k})">
<!-- Links -->
<g class="links">
{#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)}
<line
x1={coords.x1}
y1={coords.y1}
x2={coords.x2}
y2={coords.y2}
stroke-width={Math.max(1, link.strength / 25)}
class="link"
class:highlighted={isHighlighted}
class:dimmed={networkStore.selectedNodeId && !isHighlighted}
>
<title>{link.sharedTags.join(', ')}</title>
</line>
{/each}
</g>
<!-- Nodes -->
<g class="nodes">
{#each graphNodes as node (node.id)}
{@const isSelected = node.id === networkStore.selectedNodeId}
{@const isConnected = isConnectedToSelected(node.id, graphLinks)}
{@const isDimmed = networkStore.selectedNodeId && !isConnected}
<g
transform="translate({node.x ?? 0}, {node.y ?? 0})"
class="node"
class:selected={isSelected}
class:connected={isConnected && !isSelected}
class:dimmed={isDimmed}
onmousedown={(e) => handleDragStart(e, node)}
onclick={() => handleNodeClick(node)}
ondblclick={() => handleNodeDoubleClick(node)}
role="button"
tabindex="0"
aria-label={node.name}
>
<!-- Node circle -->
<circle r={isSelected ? 28 : 24} fill={stringToColor(node.name)} class="node-circle" />
<!-- Avatar image or initials -->
{#if node.photoUrl}
<clipPath id="clip-{node.id}">
<circle r={isSelected ? 26 : 22} />
</clipPath>
<image
href={node.photoUrl}
x={isSelected ? -26 : -22}
y={isSelected ? -26 : -22}
width={isSelected ? 52 : 44}
height={isSelected ? 52 : 44}
clip-path="url(#clip-{node.id})"
preserveAspectRatio="xMidYMid slice"
/>
{:else}
<text
class="node-initials"
text-anchor="middle"
dominant-baseline="central"
fill="white"
font-size={isSelected ? 14 : 12}
font-weight="600"
>
{getInitials(node.name)}
</text>
{/if}
<!-- Favorite indicator -->
{#if node.isFavorite}
<circle
cx={isSelected ? 20 : 17}
cy={isSelected ? -20 : -17}
r="8"
fill="hsl(var(--background))"
/>
<text
x={isSelected ? 20 : 17}
y={isSelected ? -20 : -17}
text-anchor="middle"
dominant-baseline="central"
font-size="10"
>
</text>
{/if}
<!-- Connection count badge -->
{#if node.connectionCount > 0}
<circle
cx={isSelected ? -20 : -17}
cy={isSelected ? -20 : -17}
r="10"
fill="hsl(var(--primary))"
/>
<text
x={isSelected ? -20 : -17}
y={isSelected ? -20 : -17}
text-anchor="middle"
dominant-baseline="central"
fill="white"
font-size="9"
font-weight="600"
>
{node.connectionCount}
</text>
{/if}
<!-- Node label -->
<text
y={isSelected ? 42 : 38}
class="node-label"
text-anchor="middle"
font-size={isSelected ? 13 : 11}
font-weight={isSelected ? '600' : '500'}
>
{node.name}
</text>
<!-- Company label -->
{#if node.company}
<text
y={isSelected ? 56 : 50}
class="node-company"
text-anchor="middle"
font-size="9"
>
{node.company}
</text>
{/if}
</g>
{/each}
</g>
</g>
</svg>
<!-- Empty state -->
{#if graphNodes.length === 0 && !networkStore.loading}
<div class="empty-state">
<div class="empty-icon">🔗</div>
<p class="empty-title">Keine Verbindungen gefunden</p>
<p class="empty-description">
Kontakte werden verbunden, wenn sie gemeinsame Tags haben. Füge Tags zu deinen Kontakten
hinzu, um das Netzwerk zu sehen.
</p>
</div>
{/if}
</div>
<style>
.network-graph-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: hsl(var(--background));
}
.network-graph-svg {
display: block;
cursor: grab;
}
.network-graph-svg:active {
cursor: grabbing;
}
/* Links */
.link {
stroke: hsl(var(--muted-foreground) / 0.3);
transition:
stroke 0.2s,
stroke-width 0.2s,
opacity 0.2s;
}
.link.highlighted {
stroke: hsl(var(--primary));
stroke-width: 3 !important;
}
.link.dimmed {
opacity: 0.1;
}
/* Nodes */
.node {
cursor: pointer;
transition: opacity 0.2s;
}
.node:hover .node-circle {
filter: brightness(1.1);
}
.node.selected .node-circle {
stroke: hsl(var(--primary));
stroke-width: 4;
}
.node.connected .node-circle {
stroke: hsl(var(--primary) / 0.5);
stroke-width: 2;
}
.node.dimmed {
opacity: 0.3;
}
.node-circle {
transition:
r 0.2s,
stroke 0.2s,
stroke-width 0.2s,
filter 0.2s;
}
.node-initials {
pointer-events: none;
user-select: none;
}
.node-label {
fill: hsl(var(--foreground));
pointer-events: none;
user-select: none;
}
.node-company {
fill: hsl(var(--muted-foreground));
pointer-events: none;
user-select: none;
}
/* Empty state */
.empty-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
padding: 2rem;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.5rem;
}
.empty-description {
color: hsl(var(--muted-foreground));
max-width: 300px;
line-height: 1.5;
}
</style>

View file

@ -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<SimulationNode> {
type: 'tag';
strength: number;
sharedTags: string[];
}
// State
let nodes = $state<SimulationNode[]>([]);
let links = $state<SimulationLink[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let selectedNodeId = $state<string | null>(null);
let simulation: Simulation<SimulationNode, SimulationLink> | null = null;
let searchQuery = $state('');
let filterTagId = $state<string | null>(null);
let filterCompany = $state<string | null>(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<string>();
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<string, { id: string; name: string; color: string | null }>();
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<SimulationNode, SimulationLink>(nodes)
.force(
'link',
forceLink<SimulationNode, SimulationLink>(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<string>();
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;
});
},
};

View file

@ -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<CommandBarItem[]> {
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}
<!-- Global Search Modal (Cmd/K) -->
<SearchModal bind:open={searchModalOpen} onClose={() => (searchModalOpen = false)} />
<CommandBar
bind:open={searchModalOpen}
onClose={() => (searchModalOpen = false)}
onSearch={handleCommandBarSearch}
onSelect={handleCommandBarSelect}
quickActions={commandBarQuickActions}
placeholder="Kontakt suchen..."
emptyText="Keine Kontakte gefunden"
searchingText="Suche..."
/>
</div>
<style>

View file

@ -0,0 +1,522 @@
<script context="module" lang="ts">
function getInitials(name: string): string {
const parts = name.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
function stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = hash % 360;
return `hsl(${hue}, 70%, 50%)`;
}
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { networkStore, type SimulationNode } from '$lib/stores/network.svelte';
import NetworkGraph from '$lib/components/network/NetworkGraph.svelte';
import NetworkControls from '$lib/components/network/NetworkControls.svelte';
import { NetworkGraphSkeleton } from '$lib/components/skeletons';
import '$lib/i18n';
let graphComponent: NetworkGraph;
function handleNodeClick(node: SimulationNode) {
// Select node (highlight connections)
networkStore.selectNode(node.id);
}
function handleZoomIn() {
graphComponent?.zoomIn();
}
function handleZoomOut() {
graphComponent?.zoomOut();
}
function handleResetZoom() {
graphComponent?.resetZoom();
}
function handleViewContact() {
if (networkStore.selectedNodeId) {
goto(`/contacts/${networkStore.selectedNodeId}`);
}
}
onMount(() => {
networkStore.loadGraph();
});
</script>
<svelte:head>
<title>Netzwerk - Contacts</title>
</svelte:head>
<div class="network-page">
<!-- Controls (floating) -->
<div class="controls-wrapper">
<NetworkControls
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onResetZoom={handleResetZoom}
/>
</div>
<!-- Error Banner -->
{#if networkStore.error}
<div class="error-banner" role="alert">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{networkStore.error}</span>
</div>
{/if}
<!-- Main Content -->
<div class="graph-container">
{#if networkStore.loading}
<NetworkGraphSkeleton />
{:else}
<NetworkGraph bind:this={graphComponent} onNodeClick={handleNodeClick} />
{/if}
</div>
<!-- Selected Node Sidebar -->
{#if networkStore.selectedNode}
{@const node = networkStore.selectedNode}
{@const connectedNodes = networkStore.getConnectedNodes(node.id)}
{@const nodeLinks = networkStore.getNodeLinks(node.id)}
<div class="sidebar">
<div class="sidebar-header">
<h2 class="sidebar-title">Ausgewählter Kontakt</h2>
<button
class="sidebar-close"
onclick={() => networkStore.selectNode(null)}
aria-label="Schließen"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="sidebar-content">
<!-- Contact Info -->
<div class="contact-card">
<div class="contact-avatar" style="background: {stringToColor(node.name)}">
{#if node.photoUrl}
<img src={node.photoUrl} alt={node.name} />
{:else}
{getInitials(node.name)}
{/if}
</div>
<div class="contact-info">
<h3 class="contact-name">{node.name}</h3>
{#if node.company}
<p class="contact-company">{node.company}</p>
{/if}
</div>
{#if node.isFavorite}
<span class="favorite-badge"></span>
{/if}
</div>
<!-- Tags -->
{#if node.tags.length > 0}
<div class="section">
<h4 class="section-title">Tags</h4>
<div class="tags-list">
{#each node.tags as tag}
<span class="tag" style="background: {tag.color || 'hsl(var(--muted))'}">
{tag.name}
</span>
{/each}
</div>
</div>
{/if}
<!-- Connections -->
{#if connectedNodes.length > 0}
<div class="section">
<h4 class="section-title">
Verbindungen ({connectedNodes.length})
</h4>
<div class="connections-list">
{#each connectedNodes as connected}
{@const link = nodeLinks.find((l) => {
const sourceId = typeof l.source === 'string' ? l.source : l.source.id;
const targetId = typeof l.target === 'string' ? l.target : l.target.id;
return sourceId === connected.id || targetId === connected.id;
})}
<button
class="connection-item"
onclick={() => networkStore.selectNode(connected.id)}
>
<div
class="connection-avatar"
style="background: {stringToColor(connected.name)}"
>
{getInitials(connected.name)}
</div>
<div class="connection-info">
<span class="connection-name">{connected.name}</span>
{#if link}
<span class="connection-tags">
{link.sharedTags.join(', ')}
</span>
{/if}
</div>
</button>
{/each}
</div>
</div>
{/if}
<!-- Actions -->
<div class="sidebar-actions">
<button class="btn-primary" onclick={handleViewContact}>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
Kontakt anzeigen
</button>
</div>
</div>
</div>
{/if}
</div>
<style>
.network-page {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
}
/* Floating Controls */
.controls-wrapper {
position: absolute;
top: 5rem; /* Below the nav */
left: 50%;
transform: translateX(-50%);
z-index: 10;
max-width: calc(100% - 2rem);
}
/* Error Banner */
.error-banner {
position: absolute;
top: 5rem;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.3);
border-radius: 0.875rem;
color: hsl(var(--destructive));
backdrop-filter: blur(8px);
}
/* Graph Container - Full screen */
.graph-container {
flex: 1;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
/* Sidebar */
.sidebar {
position: fixed;
right: 1rem;
top: 50%;
transform: translateY(-50%);
width: 320px;
max-height: calc(100vh - 100px);
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 1rem;
box-shadow: 0 8px 32px hsl(var(--foreground) / 0.1);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid hsl(var(--border));
}
.sidebar-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.sidebar-close {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: none;
border: none;
color: hsl(var(--muted-foreground));
cursor: pointer;
border-radius: 0.5rem;
transition: all 0.2s;
}
.sidebar-close:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.sidebar-close svg {
width: 1.25rem;
height: 1.25rem;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
}
/* Contact Card */
.contact-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.75rem;
margin-bottom: 1.25rem;
}
.contact-avatar {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 1.125rem;
overflow: hidden;
}
.contact-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.contact-info {
flex: 1;
min-width: 0;
}
.contact-name {
font-size: 1.0625rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contact-company {
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.favorite-badge {
font-size: 1rem;
}
/* Sections */
.section {
margin-bottom: 1.25rem;
}
.section-title {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
/* Tags */
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
color: white;
}
/* Connections */
.connections-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.connection-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem;
background: none;
border: 1px solid hsl(var(--border));
border-radius: 0.625rem;
cursor: pointer;
text-align: left;
transition: all 0.2s;
}
.connection-item:hover {
background: hsl(var(--muted) / 0.5);
border-color: hsl(var(--primary) / 0.3);
}
.connection-avatar {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.75rem;
flex-shrink: 0;
}
.connection-info {
flex: 1;
min-width: 0;
}
.connection-name {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.connection-tags {
display: block;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Actions */
.sidebar-actions {
padding-top: 1rem;
border-top: 1px solid hsl(var(--border));
}
.btn-primary {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 0.625rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-primary svg {
width: 1.125rem;
height: 1.125rem;
}
/* Responsive */
@media (max-width: 1024px) {
.sidebar {
position: fixed;
right: 0;
top: auto;
bottom: 0;
transform: none;
width: 100%;
max-height: 50vh;
border-radius: 1rem 1rem 0 0;
}
}
@media (max-width: 768px) {
.controls-wrapper {
top: 6rem; /* Larger nav on mobile */
width: calc(100% - 1rem);
max-width: none;
}
}
</style>