feat: major update with network graphs, themes, todo extensions, and more

## New Features

### Network Graph Visualization (Contacts, Calendar, Todo)
- D3.js force simulation for physics-based layout
- Zoom & pan with mouse/touchpad
- Keyboard shortcuts: +/- zoom, 0 reset, Esc deselect, / search, F focus
- Filtering by tags, company/location/project, connection strength
- Shared components in @manacore/shared-ui

### Central Tags API (mana-core-auth)
- CRUD endpoints for tags
- Schema: tags table with userId, name, color, app
- Shared tag components in @manacore/shared-ui

### Custom Themes System
- Theme editor with live preview and color picker
- Community theme gallery
- Theme sharing (public, unlisted, private)
- Backend API in mana-core-auth

### Todo App Extensions
- Glass-pill design for task input and items
- Settings page with 20+ preferences
- Task edit modal with inline editing
- Statistics page with visualizations
- PWA support with offline capabilities
- Multiple kanban boards

### Contacts App Features
- Duplicate detection
- Photo upload
- Batch operations
- Enhanced favorites page with multiple view modes
- Alphabet view improvements
- Search modal

### Help System
- @manacore/shared-help-content
- @manacore/shared-help-ui
- @manacore/shared-help-types

### Other Features
- Themes page for all apps
- Referral system frontend
- CommandBar (global search)
- Skeleton loaders
- Settings page improvements

## Bug Fixes
- Network graph simulation initialization
- Database schema TEXT for user_id columns (Better Auth compatibility)
- Various styling fixes

## Documentation
- Daily report for 2025-12-10
- CI/CD deployment guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-10 02:37:46 +01:00
parent e84371aa94
commit ee42b6cc76
381 changed files with 39284 additions and 6275 deletions

View file

@ -0,0 +1,604 @@
<script lang="ts">
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X, Focus, Keyboard } from 'lucide-svelte';
import type { NetworkTag } from './network.types';
interface Props {
searchQuery?: string;
tags?: NetworkTag[];
selectedTagId?: string | null;
subtitles?: string[];
selectedSubtitle?: string | null;
subtitleLabel?: string;
nodeCount?: number;
linkCount?: number;
nodeLabel?: string;
linkLabel?: string;
searchPlaceholder?: string;
minStrength?: number;
onSearch?: (query: string) => void;
onTagFilter?: (tagId: string | null) => void;
onSubtitleFilter?: (subtitle: string | null) => void;
onStrengthFilter?: (minStrength: number) => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
onResetZoom?: () => void;
onFocusSelected?: () => void;
onClearFilters?: () => void;
}
let {
searchQuery = '',
tags = [],
selectedTagId = null,
subtitles = [],
selectedSubtitle = null,
subtitleLabel = 'Filter',
nodeCount = 0,
linkCount = 0,
nodeLabel = 'Elemente',
linkLabel = 'Verbindungen',
searchPlaceholder = 'Suchen...',
minStrength = 0,
onSearch,
onTagFilter,
onSubtitleFilter,
onStrengthFilter,
onZoomIn,
onZoomOut,
onResetZoom,
onFocusSelected,
onClearFilters,
}: Props = $props();
let searchInput = $state(searchQuery);
let showFilters = $state(false);
let showKeyboardHelp = $state(false);
let strengthValue = $state(minStrength);
let searchInputElement: HTMLInputElement;
// Sync searchInput with external searchQuery
$effect(() => {
searchInput = searchQuery;
});
// Sync strength with external minStrength
$effect(() => {
strengthValue = minStrength;
});
function handleSearchInput(event: Event) {
const target = event.target as HTMLInputElement;
searchInput = target.value;
onSearch?.(target.value);
}
function clearSearch() {
searchInput = '';
onSearch?.('');
}
function handleTagChange(event: Event) {
const target = event.target as HTMLSelectElement;
onTagFilter?.(target.value || null);
}
function handleSubtitleChange(event: Event) {
const target = event.target as HTMLSelectElement;
onSubtitleFilter?.(target.value || null);
}
function clearAllFilters() {
searchInput = '';
strengthValue = 0;
onClearFilters?.();
}
function handleStrengthChange(event: Event) {
const target = event.target as HTMLInputElement;
strengthValue = parseInt(target.value, 10);
onStrengthFilter?.(strengthValue);
}
function focusSearch() {
searchInputElement?.focus();
}
const hasActiveFilters = $derived(
searchQuery || selectedTagId || selectedSubtitle || minStrength > 0
);
// Keyboard shortcuts info
const keyboardShortcuts = [
{ key: '+/-', description: 'Zoom in/out' },
{ key: '0', description: 'Reset zoom' },
{ key: 'F', description: 'Fokus auf Auswahl' },
{ key: '/', description: 'Suche fokussieren' },
{ key: 'Esc', description: 'Auswahl aufheben' },
];
// Export focus function for parent
export { focusSearch };
</script>
<div class="network-controls">
<!-- Search bar -->
<div class="search-container">
<Search size={18} class="search-icon" />
<input
bind:this={searchInputElement}
type="text"
placeholder={searchPlaceholder}
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 -->
{#if tags.length > 0 || subtitles.length > 0}
<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>
{/if}
<!-- 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 (0)"
>
<RotateCcw size={18} />
</button>
<button
onclick={onFocusSelected}
class="control-btn"
aria-label="Auf Auswahl fokussieren"
title="Fokus auf Auswahl (F)"
>
<Focus size={18} />
</button>
</div>
<!-- Keyboard help toggle -->
<button
onclick={() => (showKeyboardHelp = !showKeyboardHelp)}
class="control-btn"
class:active={showKeyboardHelp}
aria-label="Tastaturkürzel anzeigen"
title="Tastaturkürzel"
>
<Keyboard size={18} />
</button>
<!-- Stats -->
<div class="stats">
<span class="stat">
{nodeCount}
{nodeLabel}
</span>
<span class="stat-divider"></span>
<span class="stat">
{linkCount}
{linkLabel}
</span>
</div>
</div>
<!-- Keyboard shortcuts help -->
{#if showKeyboardHelp}
<div class="keyboard-help">
<div class="keyboard-help-title">Tastaturkürzel</div>
<div class="keyboard-shortcuts">
{#each keyboardShortcuts as shortcut}
<div class="shortcut">
<kbd class="shortcut-key">{shortcut.key}</kbd>
<span class="shortcut-desc">{shortcut.description}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Filter panel -->
{#if showFilters}
<div class="filter-panel">
<div class="filter-row">
<!-- Tag filter -->
{#if tags.length > 0}
<div class="filter-group">
<label for="tag-filter" class="filter-label">Tag</label>
<select
id="tag-filter"
onchange={handleTagChange}
value={selectedTagId || ''}
class="filter-select"
>
<option value="">Alle Tags</option>
{#each tags as tag}
<option value={tag.id}>
{tag.name}
</option>
{/each}
</select>
</div>
{/if}
<!-- Subtitle filter (e.g., Company, Project) -->
{#if subtitles.length > 0}
<div class="filter-group">
<label for="subtitle-filter" class="filter-label">{subtitleLabel}</label>
<select
id="subtitle-filter"
onchange={handleSubtitleChange}
value={selectedSubtitle || ''}
class="filter-select"
>
<option value="">Alle</option>
{#each subtitles as subtitle}
<option value={subtitle}>
{subtitle}
</option>
{/each}
</select>
</div>
{/if}
<!-- Strength filter slider -->
<div class="filter-group strength-group">
<label for="strength-filter" class="filter-label">
Min. Stärke: {strengthValue}%
</label>
<input
id="strength-filter"
type="range"
min="0"
max="100"
step="10"
value={strengthValue}
oninput={handleStrengthChange}
class="strength-slider"
/>
</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) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 9999px;
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) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 1rem;
}
.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);
}
/* Strength slider */
.strength-group {
min-width: 180px;
}
.strength-slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: hsl(var(--muted));
appearance: none;
cursor: pointer;
}
.strength-slider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: hsl(var(--primary));
cursor: pointer;
transition: transform 0.1s;
}
.strength-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.strength-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border: none;
border-radius: 50%;
background: hsl(var(--primary));
cursor: pointer;
}
/* Keyboard help panel */
.keyboard-help {
margin-top: 0.5rem;
padding: 0.75rem 1rem;
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 1rem;
}
.keyboard-help-title {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.keyboard-shortcuts {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.shortcut {
display: flex;
align-items: center;
gap: 0.5rem;
}
.shortcut-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
padding: 0.25rem 0.5rem;
background: hsl(var(--muted));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
font-family: monospace;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.shortcut-desc {
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
}
@media (max-width: 640px) {
.network-controls {
flex-direction: column;
align-items: stretch;
}
.search-container {
max-width: none;
}
.zoom-controls {
padding-left: 0;
border-left: none;
justify-content: center;
}
.stats {
justify-content: center;
margin-left: 0;
}
}
</style>

View file

@ -0,0 +1,671 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { zoom, zoomIdentity, type ZoomBehavior } from 'd3-zoom';
import { select, type Selection } from 'd3-selection';
import 'd3-transition'; // Side-effect import for .transition() method
import type { SimulationNode, SimulationLink, NetworkTransform } from './network.types';
import { stringToColor, getInitials, NODE_CONFIG, LABEL_CONFIG } from './constants';
interface Props {
nodes: SimulationNode[];
links: SimulationLink[];
selectedNodeId?: string | null;
onNodeClick?: (node: SimulationNode) => void;
onNodeDoubleClick?: (node: SimulationNode) => void;
onBackgroundClick?: () => void;
onDragStart?: (node: SimulationNode) => void;
onDrag?: (node: SimulationNode, x: number, y: number) => void;
onDragEnd?: (node: SimulationNode) => void;
onFocusSearch?: () => void;
}
let {
nodes,
links,
selectedNodeId = null,
onNodeClick,
onNodeDoubleClick,
onBackgroundClick,
onDragStart,
onDrag,
onDragEnd,
onFocusSearch,
}: Props = $props();
let svgElement: SVGSVGElement;
let containerElement: HTMLDivElement;
let zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> | null = null;
let transform = $state<NetworkTransform>({ x: 0, y: 0, k: 1 });
let draggedNode: SimulationNode | null = null;
// Tooltip state
let hoveredLink = $state<SimulationLink | null>(null);
let tooltipPosition = $state({ x: 0, y: 0 });
// 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);
}
});
function handleNodeClick(node: SimulationNode) {
onNodeClick?.(node);
}
function handleBackgroundClick(event: MouseEvent) {
if (event.target === svgElement) {
onBackgroundClick?.();
}
}
function handleNodeDoubleClick(node: SimulationNode) {
onNodeDoubleClick?.(node);
}
function handleDragStart(event: MouseEvent, node: SimulationNode) {
event.stopPropagation();
draggedNode = node;
onDragStart?.(node);
}
function handleDrag(event: MouseEvent) {
if (!draggedNode || !svgElement) return;
const rect = svgElement.getBoundingClientRect();
const x = (event.clientX - rect.left - transform.x) / transform.k;
const y = (event.clientY - rect.top - transform.y) / transform.k;
onDrag?.(draggedNode, x, y);
}
function handleDragEnd() {
if (draggedNode) {
onDragEnd?.(draggedNode);
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);
}
}
function focusOnSelectedNode() {
if (!selectedNodeId || !svgElement || !zoomBehavior || !containerElement) return;
const node = nodes.find((n) => n.id === selectedNodeId);
if (!node || node.x === undefined || node.y === undefined) return;
const rect = containerElement.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
// Calculate transform to center on node
const scale = 1.5;
const x = centerX - node.x * scale;
const y = centerY - node.y * scale;
select(svgElement)
.transition()
.duration(500)
.call(zoomBehavior.transform, zoomIdentity.translate(x, y).scale(scale));
}
// Keyboard shortcuts
function handleKeydown(event: KeyboardEvent) {
// Ignore if typing in an input
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLSelectElement
) {
return;
}
switch (event.key) {
case '+':
case '=':
event.preventDefault();
zoomIn();
break;
case '-':
case '_':
event.preventDefault();
zoomOut();
break;
case '0':
event.preventDefault();
resetZoom();
break;
case 'Escape':
event.preventDefault();
onBackgroundClick?.();
break;
case 'f':
case 'F':
if (!event.ctrlKey && !event.metaKey) {
event.preventDefault();
focusOnSelectedNode();
}
break;
case '/':
event.preventDefault();
onFocusSearch?.();
break;
}
}
// Setup keyboard listener
$effect(() => {
if (typeof window !== 'undefined') {
window.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('keydown', handleKeydown);
};
}
});
// 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,
};
}
// Link hover handlers
function handleLinkMouseEnter(event: MouseEvent, link: SimulationLink) {
hoveredLink = link;
updateTooltipPosition(event);
}
function handleLinkMouseMove(event: MouseEvent) {
if (hoveredLink) {
updateTooltipPosition(event);
}
}
function handleLinkMouseLeave() {
hoveredLink = null;
}
function updateTooltipPosition(event: MouseEvent) {
if (!containerElement) return;
const rect = containerElement.getBoundingClientRect();
tooltipPosition = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}
// Get node names for tooltip
function getLinkNodeNames(link: SimulationLink): { source: string; target: string } {
const source =
typeof link.source === 'string' ? nodes.find((n) => n.id === link.source) : link.source;
const target =
typeof link.target === 'string' ? nodes.find((n) => n.id === link.target) : link.target;
return {
source: source?.name ?? 'Unknown',
target: target?.name ?? 'Unknown',
};
}
// Check if a node is connected to selected node
function isConnectedToSelected(nodeId: string): boolean {
if (!selectedNodeId) return false;
if (nodeId === 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 === selectedNodeId && targetId === nodeId) ||
(targetId === selectedNodeId && sourceId === nodeId)
);
});
}
// Export functions for parent component
export { resetZoom, zoomIn, zoomOut, focusOnSelectedNode };
</script>
<div
bind:this={containerElement}
class="network-graph-container"
onmousemove={handleDrag}
onmouseup={handleDragEnd}
onmouseleave={handleDragEnd}
role="application"
aria-label="Network Graph"
>
<svg
bind:this={svgElement}
class="network-graph-svg"
style="width: 100%; height: 100%;"
onclick={handleBackgroundClick}
>
<g transform="translate({transform.x}, {transform.y}) scale({transform.k})">
<!-- Links -->
<g class="links">
{#each links 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 =
selectedNodeId && (sourceId === selectedNodeId || targetId === selectedNodeId)}
<!-- Invisible wider line for easier hover -->
<line
x1={coords.x1}
y1={coords.y1}
x2={coords.x2}
y2={coords.y2}
stroke="transparent"
stroke-width="20"
class="link-hitbox"
onmouseenter={(e) => handleLinkMouseEnter(e, link)}
onmousemove={handleLinkMouseMove}
onmouseleave={handleLinkMouseLeave}
/>
<!-- Visible link -->
<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={selectedNodeId && !isHighlighted}
class:hovered={hoveredLink === link}
pointer-events="none"
/>
{/each}
</g>
<!-- Nodes -->
<g class="nodes">
{#each nodes as node (node.id)}
{@const isSelected = node.id === selectedNodeId}
{@const isConnected = isConnectedToSelected(node.id)}
{@const isDimmed = selectedNodeId && !isConnected}
{@const nodeRadius = isSelected ? NODE_CONFIG.selectedRadius : NODE_CONFIG.radius}
{@const avatarRadius = isSelected
? NODE_CONFIG.selectedAvatarRadius
: NODE_CONFIG.avatarRadius}
{@const badgeOffset = isSelected
? NODE_CONFIG.selectedBadgeOffset
: NODE_CONFIG.badgeOffset}
<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={nodeRadius} fill={stringToColor(node.name)} class="node-circle" />
<!-- Avatar image or initials -->
{#if node.photoUrl}
<clipPath id="clip-{node.id}">
<circle r={avatarRadius} />
</clipPath>
<image
href={node.photoUrl}
x={-avatarRadius}
y={-avatarRadius}
width={avatarRadius * 2}
height={avatarRadius * 2}
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
? LABEL_CONFIG.selectedInitialsFontSize
: LABEL_CONFIG.initialsFontSize}
font-weight="600"
>
{getInitials(node.name)}
</text>
{/if}
<!-- Favorite indicator -->
{#if node.isFavorite}
<circle cx={badgeOffset} cy={-badgeOffset} r="10" fill="hsl(var(--background))" />
<text
x={badgeOffset}
y={-badgeOffset}
text-anchor="middle"
dominant-baseline="central"
font-size="12"
>
&#11088;
</text>
{/if}
<!-- Connection count badge -->
{#if node.connectionCount > 0}
<circle cx={-badgeOffset} cy={-badgeOffset} r="12" fill="hsl(var(--primary))" />
<text
x={-badgeOffset}
y={-badgeOffset}
text-anchor="middle"
dominant-baseline="central"
fill="white"
font-size="11"
font-weight="600"
>
{node.connectionCount}
</text>
{/if}
<!-- Node label (counter-scaled for zoom independence) -->
<g transform="scale({1 / transform.k})">
<text
y={(isSelected ? LABEL_CONFIG.selectedNameOffset : LABEL_CONFIG.nameOffset) *
transform.k}
class="node-label"
text-anchor="middle"
font-size={isSelected
? LABEL_CONFIG.selectedNameFontSize
: LABEL_CONFIG.nameFontSize}
font-weight={isSelected ? '600' : '500'}
>
{node.name}
</text>
<!-- Subtitle label (e.g., company) -->
{#if node.subtitle}
{@const labelOffset =
(isSelected ? LABEL_CONFIG.selectedNameOffset : LABEL_CONFIG.nameOffset) *
transform.k}
<text
y={labelOffset + LABEL_CONFIG.subtitleGap}
class="node-subtitle"
text-anchor="middle"
font-size={LABEL_CONFIG.subtitleFontSize}
>
{node.subtitle}
</text>
{/if}
</g>
</g>
{/each}
</g>
</g>
</svg>
<!-- Empty state -->
{#if nodes.length === 0}
<div class="empty-state">
<div class="empty-icon">&#128279;</div>
<p class="empty-title">Keine Verbindungen gefunden</p>
<p class="empty-description">Elemente werden verbunden, wenn sie gemeinsame Tags haben.</p>
</div>
{/if}
<!-- Link tooltip -->
{#if hoveredLink}
{@const names = getLinkNodeNames(hoveredLink)}
<div class="link-tooltip" style="left: {tooltipPosition.x}px; top: {tooltipPosition.y}px;">
<div class="tooltip-header">
<span class="tooltip-source">{names.source}</span>
<span class="tooltip-arrow"></span>
<span class="tooltip-target">{names.target}</span>
</div>
<div class="tooltip-strength">
<span class="strength-label">Stärke:</span>
<span class="strength-value">{hoveredLink.strength}%</span>
<div class="strength-bar">
<div class="strength-fill" style="width: {hoveredLink.strength}%"></div>
</div>
</div>
<div class="tooltip-tags">
{#each hoveredLink.sharedTags as tag}
<span class="tooltip-tag">{tag}</span>
{/each}
</div>
</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;
}
.link.hovered {
stroke: hsl(var(--primary));
stroke-width: 3 !important;
}
.link-hitbox {
cursor: pointer;
}
/* 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-subtitle {
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;
}
/* Link tooltip */
.link-tooltip {
position: absolute;
transform: translate(-50%, -100%) translateY(-12px);
padding: 0.75rem 1rem;
background: hsl(var(--popover));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.1);
pointer-events: none;
z-index: 100;
min-width: 200px;
max-width: 300px;
}
.tooltip-header {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
}
.tooltip-arrow {
color: hsl(var(--muted-foreground));
}
.tooltip-strength {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.625rem;
font-size: 0.75rem;
}
.strength-label {
color: hsl(var(--muted-foreground));
}
.strength-value {
font-weight: 600;
color: hsl(var(--primary));
}
.strength-bar {
flex: 1;
height: 4px;
background: hsl(var(--muted));
border-radius: 2px;
overflow: hidden;
}
.strength-fill {
height: 100%;
background: hsl(var(--primary));
border-radius: 2px;
transition: width 0.2s;
}
.tooltip-tags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.tooltip-tag {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
background: hsl(var(--primary) / 0.1);
border: 1px solid hsl(var(--primary) / 0.2);
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--primary));
}
</style>

View file

@ -0,0 +1,86 @@
/**
* Generate a consistent HSL color from a string
* @param str - Input string (e.g., name)
* @returns HSL color string
*/
export 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 initials from a name
* @param name - Full name
* @returns 1-2 character initials
*/
export function getInitials(name: string): string {
const parts = name.trim().split(' ').filter(Boolean);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
/**
* D3 Force simulation default parameters
*/
export const SIMULATION_CONFIG = {
/** Distance between linked nodes */
linkDistance: 100,
/** Strength of links (0-1) */
linkStrength: 0.5,
/** Charge strength (negative = repulsion) */
chargeStrength: -300,
/** Collision radius for nodes */
collisionRadius: 50,
/** Initial alpha for simulation */
initialAlpha: 1,
/** Alpha for reheating simulation */
reheatAlpha: 0.3,
/** Zoom scale extent */
zoomExtent: [0.1, 4] as [number, number],
} as const;
/**
* Node size configuration
*/
export const NODE_CONFIG = {
/** Default node radius */
radius: 36,
/** Selected node radius */
selectedRadius: 40,
/** Avatar clip radius (slightly smaller than node) */
avatarRadius: 34,
/** Selected avatar clip radius */
selectedAvatarRadius: 38,
/** Badge offset from center */
badgeOffset: 25,
/** Selected badge offset */
selectedBadgeOffset: 28,
} as const;
/**
* Label configuration
*/
export const LABEL_CONFIG = {
/** Font size for name label */
nameFontSize: 18,
/** Selected name font size */
selectedNameFontSize: 20,
/** Font size for subtitle label */
subtitleFontSize: 14,
/** Y offset for name label */
nameOffset: 58,
/** Selected name Y offset */
selectedNameOffset: 62,
/** Gap between name and subtitle */
subtitleGap: 22,
/** Font size for initials */
initialsFontSize: 18,
/** Selected initials font size */
selectedInitialsFontSize: 20,
} as const;

View file

@ -0,0 +1,25 @@
// Components
export { default as NetworkGraph } from './NetworkGraph.svelte';
export { default as NetworkControls } from './NetworkControls.svelte';
// Types
export type {
NetworkNode,
NetworkLink,
NetworkTag,
NetworkTransform,
NetworkGraphProps,
NetworkControlsProps,
NetworkGraphResponse,
SimulationNode,
SimulationLink,
} from './network.types';
// Constants & Helpers
export {
stringToColor,
getInitials,
SIMULATION_CONFIG,
NODE_CONFIG,
LABEL_CONFIG,
} from './constants';

View file

@ -0,0 +1,112 @@
import type { SimulationNodeDatum } from 'd3-force';
/**
* Tag attached to a network node
*/
export interface NetworkTag {
id: string;
name: string;
color: string | null;
}
/**
* Base network node interface (before D3 simulation)
*/
export interface NetworkNode {
id: string;
name: string;
photoUrl?: string | null;
subtitle?: string | null; // e.g., Company, Project, Category
isFavorite?: boolean;
tags: NetworkTag[];
connectionCount: number;
}
/**
* Network node with D3 simulation properties
*/
export interface SimulationNode extends NetworkNode, SimulationNodeDatum {
x?: number;
y?: number;
vx?: number;
vy?: number;
fx?: number | null;
fy?: number | null;
}
/**
* Network link between nodes
*/
export interface NetworkLink {
source: string;
target: string;
type: 'tag';
strength: number; // 0-100, based on shared tag count
sharedTags: string[];
}
/**
* Network link with D3 simulation properties
* Note: After D3 simulation runs, source/target become SimulationNode objects
*/
export interface SimulationLink {
source: string | SimulationNode;
target: string | SimulationNode;
type: 'tag';
strength: number;
sharedTags: string[];
index?: number;
}
/**
* Zoom/pan transform state
*/
export interface NetworkTransform {
x: number;
y: number;
k: number; // scale factor
}
/**
* Props for NetworkGraph component
*/
export interface NetworkGraphProps {
nodes: SimulationNode[];
links: SimulationLink[];
selectedNodeId?: string | null;
onNodeClick?: (node: SimulationNode) => void;
onNodeDoubleClick?: (node: SimulationNode) => void;
onBackgroundClick?: () => void;
}
/**
* Props for NetworkControls component
*/
export interface NetworkControlsProps {
searchQuery?: string;
tags?: NetworkTag[];
selectedTagId?: string | null;
subtitles?: string[]; // e.g., companies, projects
selectedSubtitle?: string | null;
subtitleLabel?: string; // e.g., "Firma", "Projekt"
nodeCount?: number;
linkCount?: number;
nodeLabel?: string; // e.g., "Kontakte", "Tasks"
linkLabel?: string; // e.g., "Verbindungen"
searchPlaceholder?: string;
onSearch?: (query: string) => void;
onTagFilter?: (tagId: string | null) => void;
onSubtitleFilter?: (subtitle: string | null) => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
onResetZoom?: () => void;
onClearFilters?: () => void;
}
/**
* API response structure for network graph
*/
export interface NetworkGraphResponse {
nodes: NetworkNode[];
links: NetworkLink[];
}