mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 22:46:41 +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 { DuplicatesModule } from './duplicates/duplicates.module';
|
||||||
import { PhotoModule } from './photo/photo.module';
|
import { PhotoModule } from './photo/photo.module';
|
||||||
import { BatchModule } from './batch/batch.module';
|
import { BatchModule } from './batch/batch.module';
|
||||||
|
import { NetworkModule } from './network/network.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -31,6 +32,7 @@ import { BatchModule } from './batch/batch.module';
|
||||||
DuplicatesModule,
|
DuplicatesModule,
|
||||||
PhotoModule,
|
PhotoModule,
|
||||||
BatchModule,
|
BatchModule,
|
||||||
|
NetworkModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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/kit": "^2.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
"@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",
|
"@types/node": "^20.0.0",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
"prettier-plugin-svelte": "^3.1.2",
|
"prettier-plugin-svelte": "^3.1.2",
|
||||||
|
|
@ -32,6 +35,9 @@
|
||||||
"@manacore/shared-branding": "workspace:*",
|
"@manacore/shared-branding": "workspace:*",
|
||||||
"@manacore/shared-feedback-service": "workspace:*",
|
"@manacore/shared-feedback-service": "workspace:*",
|
||||||
"@manacore/shared-feedback-ui": "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-i18n": "workspace:*",
|
||||||
"@manacore/shared-icons": "workspace:*",
|
"@manacore/shared-icons": "workspace:*",
|
||||||
"@manacore/shared-profile-ui": "workspace:*",
|
"@manacore/shared-profile-ui": "workspace:*",
|
||||||
|
|
@ -41,6 +47,10 @@
|
||||||
"@manacore/shared-theme-ui": "workspace:*",
|
"@manacore/shared-theme-ui": "workspace:*",
|
||||||
"@manacore/shared-ui": "workspace:*",
|
"@manacore/shared-ui": "workspace:*",
|
||||||
"@manacore/shared-utils": "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"
|
"svelte-i18n": "^4.0.1"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"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 { page } from '$app/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { locale } from 'svelte-i18n';
|
import { locale } from 'svelte-i18n';
|
||||||
import { PillNavigation } from '@manacore/shared-ui';
|
import { PillNavigation, CommandBar } from '@manacore/shared-ui';
|
||||||
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
|
import type {
|
||||||
|
PillNavItem,
|
||||||
|
PillDropdownItem,
|
||||||
|
CommandBarItem,
|
||||||
|
QuickAction,
|
||||||
|
} from '@manacore/shared-ui';
|
||||||
import { theme } from '$lib/stores/theme';
|
import { theme } from '$lib/stores/theme';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||||
|
|
@ -17,8 +22,8 @@
|
||||||
import { getPillAppItems } from '@manacore/shared-branding';
|
import { getPillAppItems } from '@manacore/shared-branding';
|
||||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||||
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
|
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
|
||||||
import SearchModal from '$lib/components/SearchModal.svelte';
|
|
||||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||||
|
import { contactsApi } from '$lib/api/contacts';
|
||||||
import { viewModeStore } from '$lib/stores/view-mode.svelte';
|
import { viewModeStore } from '$lib/stores/view-mode.svelte';
|
||||||
import { contactsSettings } from '$lib/stores/settings.svelte';
|
import { contactsSettings } from '$lib/stores/settings.svelte';
|
||||||
|
|
||||||
|
|
@ -152,6 +157,41 @@
|
||||||
goto('/', { replaceState: false });
|
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 () => {
|
onMount(async () => {
|
||||||
// Redirect to login if not authenticated
|
// Redirect to login if not authenticated
|
||||||
if (!authStore.isAuthenticated) {
|
if (!authStore.isAuthenticated) {
|
||||||
|
|
@ -238,7 +278,16 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Global Search Modal (Cmd/K) -->
|
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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