feat(contacts): unify network page toolbar with ExpandableToolbar pattern

Migrate network page from custom floating NetworkControls to the shared
ExpandableToolbar component, matching the homepage toolbar behavior.

Changes:
- Add NetworkToolbar and NetworkToolbarContent components
- Extend networkStore with toolbar state and zoom control methods
- Register graphComponent in store for toolbar zoom access
- Remove floating NetworkControls from network page
- Add toolbar rendering for /network route in layout

🤖 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-14 16:32:59 +01:00
parent 3f27e477dd
commit 68626227e0
9 changed files with 451 additions and 305 deletions

View file

@ -0,0 +1,28 @@
<script lang="ts">
import { ExpandableToolbar } from '@manacore/shared-ui';
import NetworkToolbarContent from './NetworkToolbarContent.svelte';
import { networkStore } from '$lib/stores/network.svelte';
interface Props {
isSidebarMode?: boolean;
}
let { isSidebarMode = false }: Props = $props();
// Use store for collapsed state
let isCollapsed = $derived(networkStore.isToolbarCollapsed);
function handleCollapsedChange(collapsed: boolean) {
networkStore.setToolbarCollapsed(collapsed);
}
</script>
<ExpandableToolbar
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
{isSidebarMode}
collapsedTitle="Netzwerk-Optionen"
expandedTitle="Schließen"
>
<NetworkToolbarContent />
</ExpandableToolbar>

View file

@ -0,0 +1,305 @@
<script lang="ts">
import { ZoomIn, ZoomOut, RotateCcw, Focus, X } from 'lucide-svelte';
import { networkStore } from '$lib/stores/network.svelte';
let strengthValue = $state(networkStore.minStrength);
// Sync strength with store
$effect(() => {
strengthValue = networkStore.minStrength;
});
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 handleStrengthChange(event: Event) {
const target = event.target as HTMLInputElement;
strengthValue = parseInt(target.value, 10);
networkStore.setMinStrength(strengthValue);
}
function clearAllFilters() {
strengthValue = 0;
networkStore.clearFilters();
}
const hasActiveFilters = $derived(
networkStore.filterTagId || networkStore.filterCompany || networkStore.minStrength > 0
);
</script>
<div class="toolbar-content-inner">
<!-- Tag Filter -->
{#if networkStore.uniqueTags.length > 0}
<div class="filter-group">
<select
onchange={handleTagChange}
value={networkStore.filterTagId || ''}
class="filter-select"
title="Tag filtern"
>
<option value="">Alle Tags</option>
{#each networkStore.uniqueTags as tag}
<option value={tag.id}>{tag.name}</option>
{/each}
</select>
</div>
{/if}
<!-- Company Filter -->
{#if networkStore.uniqueCompanies.length > 0}
<div class="filter-group">
<select
onchange={handleCompanyChange}
value={networkStore.filterCompany || ''}
class="filter-select"
title="Firma filtern"
>
<option value="">Alle Firmen</option>
{#each networkStore.uniqueCompanies as company}
<option value={company}>{company}</option>
{/each}
</select>
</div>
{/if}
<div class="toolbar-divider"></div>
<!-- Strength Filter -->
<div class="strength-group">
<label for="network-strength-filter" class="strength-label">
Stärke: {strengthValue}%
</label>
<input
id="network-strength-filter"
type="range"
min="0"
max="100"
step="10"
value={strengthValue}
oninput={handleStrengthChange}
class="strength-slider"
title="Mindest-Verbindungsstärke"
/>
</div>
<div class="toolbar-divider"></div>
<!-- Zoom Controls -->
<div class="zoom-controls">
<button
onclick={() => networkStore.zoomIn()}
class="control-btn"
aria-label="Vergrößern"
title="Vergrößern (+)"
>
<ZoomIn size={16} />
</button>
<button
onclick={() => networkStore.zoomOut()}
class="control-btn"
aria-label="Verkleinern"
title="Verkleinern (-)"
>
<ZoomOut size={16} />
</button>
<button
onclick={() => networkStore.resetZoom()}
class="control-btn"
aria-label="Ansicht zurücksetzen"
title="Zurücksetzen (0)"
>
<RotateCcw size={16} />
</button>
<button
onclick={() => networkStore.focusOnSelected()}
class="control-btn"
aria-label="Auf Auswahl fokussieren"
title="Fokus auf Auswahl (F)"
>
<Focus size={16} />
</button>
</div>
<!-- Clear Filters -->
{#if hasActiveFilters}
<div class="toolbar-divider"></div>
<button onclick={clearAllFilters} class="clear-btn" title="Filter löschen">
<X size={14} />
<span>Filter löschen</span>
</button>
{/if}
<!-- 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>
<style>
.toolbar-content-inner {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.toolbar-divider {
width: 1px;
height: 1.5rem;
background: hsl(var(--color-border));
margin: 0 0.25rem;
}
.filter-group {
display: flex;
align-items: center;
}
.filter-select {
padding: 0.375rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
cursor: pointer;
transition: border-color 0.15s;
}
.filter-select:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
.filter-select:hover {
border-color: hsl(var(--color-muted-foreground));
}
.strength-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.strength-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
}
.strength-slider {
width: 80px;
height: 4px;
border-radius: 2px;
background: hsl(var(--color-muted));
appearance: none;
cursor: pointer;
}
.strength-slider::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: hsl(var(--color-primary));
cursor: pointer;
transition: transform 0.1s;
}
.strength-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.strength-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border: none;
border-radius: 50%;
background: hsl(var(--color-primary));
cursor: pointer;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 0.125rem;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: transparent;
border: none;
border-radius: 9999px;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
transition: all 0.15s ease;
}
.control-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.control-btn:active {
transform: scale(0.95);
}
.clear-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
background: hsl(var(--destructive) / 0.1);
border: none;
border-radius: 0.5rem;
color: hsl(var(--destructive));
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.clear-btn:hover {
background: hsl(var(--destructive) / 0.15);
}
.stats {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.stat-divider {
opacity: 0.5;
}
@media (max-width: 640px) {
.stats {
display: none;
}
.strength-group {
flex: 1;
min-width: 120px;
}
.strength-slider {
flex: 1;
}
}
</style>

View file

@ -4,7 +4,6 @@
const modes: { id: ViewMode; icon: string; label: string }[] = [
{ id: 'alphabet', icon: 'alphabet', label: 'views.alphabet' },
{ id: 'list', icon: 'list', label: 'views.list' },
{ id: 'grid', icon: 'grid', label: 'views.grid' },
];
</script>
@ -18,16 +17,7 @@
onclick={() => viewModeStore.setMode(mode.id)}
title={$_(mode.label)}
>
{#if mode.icon === 'list'}
<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="M4 6h16M4 12h16M4 18h16"
/>
</svg>
{:else if mode.icon === 'grid'}
{#if mode.icon === 'grid'}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"

View file

@ -1,210 +0,0 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { Contact } from '$lib/api/contacts';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
interface Props {
contacts: Contact[];
onContactClick: (id: string) => void;
onToggleFavorite: (e: MouseEvent, id: string) => void;
selectionMode?: boolean;
selectedIds?: Set<string>;
onToggleSelection?: (id: string) => void;
showNewContactCard?: boolean;
}
let {
contacts,
onContactClick,
onToggleFavorite,
selectionMode = false,
selectedIds = new Set(),
onToggleSelection,
showNewContactCard = true,
}: Props = $props();
function getInitials(contact: Contact) {
const first = contact.firstName?.[0] || '';
const last = contact.lastName?.[0] || '';
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
}
function getDisplayName(contact: Contact) {
if (contact.displayName) return contact.displayName;
if (contact.firstName || contact.lastName) {
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
}
return contact.email || 'Unbekannt';
}
function handleCheckboxClick(e: MouseEvent, id: string) {
e.stopPropagation();
onToggleSelection?.(id);
}
</script>
<div class="space-y-2">
<!-- New Contact Card -->
{#if showNewContactCard && !selectionMode}
<div
role="button"
tabindex="0"
onclick={() => newContactModalStore.open()}
onkeydown={(e) => e.key === 'Enter' && newContactModalStore.open()}
class="contact-card new-contact-card w-full text-left cursor-pointer"
>
<!-- Plus Avatar -->
<div class="avatar new-contact-avatar">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</div>
<!-- Text -->
<div class="flex-1 min-w-0">
<div class="font-medium text-foreground">
{$_('contacts.new')}
</div>
<div class="text-sm text-muted-foreground">
{$_('contacts.addFirst')}
</div>
</div>
</div>
{/if}
{#each contacts as contact (contact.id)}
<div
role="button"
tabindex="0"
onclick={() => onContactClick(contact.id)}
onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
class="contact-card w-full text-left cursor-pointer {selectionMode &&
selectedIds.has(contact.id)
? 'selected'
: ''}"
>
<!-- Selection Checkbox -->
{#if selectionMode}
<button
type="button"
onclick={(e) => handleCheckboxClick(e, contact.id)}
class="selection-checkbox"
aria-label={selectedIds.has(contact.id) ? 'Auswahl aufheben' : 'Auswählen'}
>
{#if selectedIds.has(contact.id)}
<svg class="w-5 h-5 text-primary" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
{:else}
<div class="w-5 h-5 rounded border-2 border-border"></div>
{/if}
</button>
{/if}
<!-- Avatar -->
<div class="avatar">
{#if contact.photoUrl}
<img
src={contact.photoUrl}
alt={getDisplayName(contact)}
class="h-full w-full rounded-full object-cover"
/>
{:else}
{getInitials(contact)}
{/if}
</div>
<!-- Contact Info -->
<div class="flex-1 min-w-0">
<div class="font-medium text-foreground truncate">
{getDisplayName(contact)}
</div>
{#if contact.company || contact.jobTitle}
<div class="text-sm text-muted-foreground truncate">
{[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
</div>
{/if}
{#if contact.email}
<div class="text-sm text-muted-foreground truncate">
{contact.email}
</div>
{/if}
</div>
<!-- Favorite button -->
<button
onclick={(e) => onToggleFavorite(e, contact.id)}
class="p-2 rounded-full hover:bg-accent transition-colors"
>
{#if contact.isFavorite}
<svg class="h-5 w-5 text-red-500 fill-current" viewBox="0 0 24 24">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
{:else}
<svg
class="h-5 w-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
{/if}
</button>
</div>
{/each}
</div>
<style>
.selection-checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
transition: background 0.2s ease;
flex-shrink: 0;
}
.selection-checkbox:hover {
background: hsl(var(--color-muted));
}
:global(.contact-card.selected) {
background: hsl(var(--color-primary) / 0.1) !important;
border-color: hsl(var(--color-primary) / 0.3) !important;
}
/* New Contact Card */
.new-contact-card {
border-style: dashed;
border-color: hsl(var(--color-primary) / 0.4);
background: hsl(var(--color-primary) / 0.05);
}
.new-contact-card:hover {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.new-contact-avatar {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
}
</style>

View file

@ -17,11 +17,43 @@ import type {
SimulationNode as SharedSimulationNode,
SimulationLink as SharedSimulationLink,
} from '@manacore/shared-ui';
import { NetworkGraph } from '@manacore/shared-ui';
// Re-export types from shared-ui for convenience
export type SimulationNode = SharedSimulationNode;
export type SimulationLink = SharedSimulationLink;
// Graph component reference for zoom controls
let graphComponentRef: NetworkGraph | null = null;
// localStorage key for toolbar state
const TOOLBAR_STORAGE_KEY = 'network-toolbar-state';
// Load toolbar state from localStorage
function loadToolbarState(): boolean {
if (!browser) return true;
try {
const stored = localStorage.getItem(TOOLBAR_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return parsed.isCollapsed ?? true;
}
} catch {
// Ignore parse errors
}
return true;
}
// Save toolbar state to localStorage
function saveToolbarState(isCollapsed: boolean) {
if (!browser) return;
try {
localStorage.setItem(TOOLBAR_STORAGE_KEY, JSON.stringify({ isCollapsed }));
} catch {
// Ignore storage errors
}
}
// State
let nodes = $state<SimulationNode[]>([]);
let links = $state<SimulationLink[]>([]);
@ -37,6 +69,7 @@ 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 };
let isToolbarCollapsed = $state(loadToolbarState());
// Derived state for filtering
const filteredNodes = $derived.by(() => {
@ -159,6 +192,60 @@ export const networkStore = {
get uniqueTags() {
return uniqueTags;
},
get isToolbarCollapsed() {
return isToolbarCollapsed;
},
/**
* Set toolbar collapsed state
*/
setToolbarCollapsed(value: boolean) {
isToolbarCollapsed = value;
saveToolbarState(value);
},
/**
* Toggle toolbar collapsed state
*/
toggleToolbar() {
isToolbarCollapsed = !isToolbarCollapsed;
saveToolbarState(isToolbarCollapsed);
},
/**
* Register graph component reference for zoom controls
*/
setGraphComponent(component: NetworkGraph | null) {
graphComponentRef = component;
},
/**
* Zoom in on the graph
*/
zoomIn() {
graphComponentRef?.zoomIn();
},
/**
* Zoom out on the graph
*/
zoomOut() {
graphComponentRef?.zoomOut();
},
/**
* Reset zoom to fit all nodes
*/
resetZoom() {
graphComponentRef?.resetZoom();
},
/**
* Focus on the currently selected node
*/
focusOnSelected() {
graphComponentRef?.focusOnSelectedNode();
},
/**
* Load network graph data from API

View file

@ -8,7 +8,7 @@ import { browser } from '$app/environment';
// Settings types
export type ContactSortBy = 'name' | 'company' | 'created' | 'updated';
export type ContactSortOrder = 'asc' | 'desc';
export type ContactView = 'list' | 'grid' | 'alphabet';
export type ContactView = 'grid' | 'alphabet';
export type DateFormat = 'dd.MM.yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd';
export interface ContactsAppSettings {

View file

@ -16,7 +16,7 @@ function getInitialMode(): ViewMode {
// First check if there's a session-specific preference
const sessionMode = sessionStorage.getItem(STORAGE_KEY);
if (sessionMode === 'list' || sessionMode === 'grid' || sessionMode === 'alphabet') {
if (sessionMode === 'grid' || sessionMode === 'alphabet') {
return sessionMode;
}
@ -57,7 +57,7 @@ export const viewModeStore = {
// Check if there's a session preference
const sessionMode = sessionStorage.getItem(STORAGE_KEY);
if (sessionMode === 'list' || sessionMode === 'grid' || sessionMode === 'alphabet') {
if (sessionMode === 'grid' || sessionMode === 'alphabet') {
mode = sessionMode;
} else {
// Use default from settings

View file

@ -47,6 +47,8 @@
formatParsedContactPreview,
} from '$lib/utils/contact-parser';
import ContactsToolbar from '$lib/components/ContactsToolbar.svelte';
import NetworkToolbar from '$lib/components/NetworkToolbar.svelte';
import { networkStore } from '$lib/stores/network.svelte';
// Tags state for Quick-Create
let availableTags = $state<{ id: string; name: string }[]>([]);
@ -75,15 +77,23 @@
// Show toolbar only on main contacts page
const showContactsToolbar = $derived($page.url.pathname === '/' && !isSidebarMode);
// Show network toolbar only on network page
const showNetworkToolbar = $derived($page.url.pathname === '/network' && !isSidebarMode);
// Check if any toolbar is expanded
const isAnyToolbarExpanded = $derived(
(showContactsToolbar && !contactsFilterStore.isToolbarCollapsed) ||
(showNetworkToolbar && !networkStore.isToolbarCollapsed)
);
// Dynamic bottom offset based on toolbar state
const inputBarBottomOffset = $derived(
isSidebarMode
? '0px'
: showContactsToolbar && !contactsFilterStore.isToolbarCollapsed
? '140px'
: '70px'
isSidebarMode ? '0px' : isAnyToolbarExpanded ? '140px' : '70px'
);
// Show FAB when any toolbar is active
const showToolbarFab = $derived(showContactsToolbar || showNetworkToolbar);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
@ -387,13 +397,18 @@
primaryColor="#3b82f6"
autoFocus={true}
bottomOffset={inputBarBottomOffset}
hasFabRight={showContactsToolbar}
hasFabRight={showToolbarFab}
/>
<!-- Contacts Toolbar (FAB + expandable bar) - only on main page -->
{#if showContactsToolbar}
<ContactsToolbar {isSidebarMode} contacts={contactsStore.contacts} />
{/if}
<!-- Network Toolbar (FAB + expandable bar) - only on network page -->
{#if showNetworkToolbar}
<NetworkToolbar {isSidebarMode} />
{/if}
</div>
</SplitPaneContainer>

View file

@ -3,7 +3,7 @@
import { goto } from '$app/navigation';
import { networkStore, type SimulationNode } from '$lib/stores/network.svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import { NetworkGraph, NetworkControls } from '@manacore/shared-ui';
import { NetworkGraph } from '@manacore/shared-ui';
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
import { NetworkGraphSkeleton } from '$lib/components/skeletons';
import '$lib/i18n';
@ -62,37 +62,10 @@
networkStore.releaseNode(node.id);
}
function handleZoomIn() {
graphComponent?.zoomIn();
}
function handleZoomOut() {
graphComponent?.zoomOut();
}
function handleResetZoom() {
graphComponent?.resetZoom();
}
function handleFocusSelected() {
graphComponent?.focusOnSelectedNode();
}
function handleTagFilter(tagId: string | null) {
networkStore.setFilterTag(tagId);
}
function handleSubtitleFilter(company: string | null) {
networkStore.setFilterCompany(company);
}
function handleStrengthFilter(strength: number) {
networkStore.setMinStrength(strength);
}
function handleClearFilters() {
networkStore.clearFilters();
}
// Register graph component with store when it changes
$effect(() => {
networkStore.setGraphComponent(graphComponent);
});
// Initialize simulation when data is loaded and container is ready
$effect(() => {
@ -109,6 +82,7 @@
});
onDestroy(() => {
networkStore.setGraphComponent(null);
networkStore.stopSimulation();
});
</script>
@ -118,31 +92,6 @@
</svelte:head>
<div class="network-page">
<!-- Controls (floating) -->
<div class="controls-wrapper">
<NetworkControls
showSearch={false}
tags={networkStore.uniqueTags}
selectedTagId={networkStore.filterTagId}
subtitles={networkStore.uniqueCompanies}
selectedSubtitle={networkStore.filterCompany}
subtitleLabel="Firma"
nodeCount={networkStore.nodes.length}
linkCount={networkStore.links.length}
nodeLabel="Kontakte"
linkLabel="Verbindungen"
minStrength={networkStore.minStrength}
onTagFilter={handleTagFilter}
onSubtitleFilter={handleSubtitleFilter}
onStrengthFilter={handleStrengthFilter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onResetZoom={handleResetZoom}
onFocusSelected={handleFocusSelected}
onClearFilters={handleClearFilters}
/>
</div>
<!-- Error Banner -->
{#if networkStore.error}
<div class="error-banner" role="alert">
@ -194,16 +143,6 @@
flex-direction: column;
}
/* Floating Controls - positioned above QuickInputBar and PillNav */
.controls-wrapper {
position: fixed;
bottom: calc(140px + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
z-index: 10;
max-width: calc(100% - 2rem);
}
/* Error Banner */
.error-banner {
position: absolute;
@ -305,12 +244,4 @@
}
}
}
@media (max-width: 768px) {
.controls-wrapper {
bottom: calc(160px + env(safe-area-inset-bottom));
width: calc(100% - 2rem);
max-width: none;
}
}
</style>