mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
1dda437192
commit
2b3f92ff36
11 changed files with 2142 additions and 4 deletions
|
|
@ -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 {}
|
||||
|
|
|
|||
24
apps/contacts/apps/backend/src/network/network.controller.ts
Normal file
24
apps/contacts/apps/backend/src/network/network.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
apps/contacts/apps/backend/src/network/network.module.ts
Normal file
10
apps/contacts/apps/backend/src/network/network.module.ts
Normal 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 {}
|
||||
151
apps/contacts/apps/backend/src/network/network.service.ts
Normal file
151
apps/contacts/apps/backend/src/network/network.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
74
apps/contacts/apps/web/src/lib/api/network.ts
Normal file
74
apps/contacts/apps/web/src/lib/api/network.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
434
apps/contacts/apps/web/src/lib/stores/network.svelte.ts
Normal file
434
apps/contacts/apps/web/src/lib/stores/network.svelte.ts
Normal 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;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
522
apps/contacts/apps/web/src/routes/(app)/network/+page.svelte
Normal file
522
apps/contacts/apps/web/src/routes/(app)/network/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue