mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
3f27e477dd
commit
68626227e0
9 changed files with 451 additions and 305 deletions
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue