mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
♻️ refactor(contacts): remove statistics, network view and session storage; implement demo mode
- Remove statistics feature (stores, routes, ~560 LOC) - Remove network view with D3.js graph (~1,100 LOC) - Remove session-based contact storage - Add demo contacts for unauthenticated users (10 sample contacts) - Add auth gate prompts for create/edit/delete/favorite actions - Update layout with Demo-Modus banner and event handling - ~1,760 lines of code removed for simpler, cleaner codebase Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1dc4f58edb
commit
437d612e81
17 changed files with 517 additions and 2868 deletions
|
|
@ -1,74 +0,0 @@
|
|||
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');
|
||||
},
|
||||
};
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { sessionContactsStore } from '$lib/stores/session-contacts.svelte';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
|
|
@ -16,7 +15,7 @@
|
|||
save: {
|
||||
title: 'Anmelden um zu speichern',
|
||||
description:
|
||||
'Melde dich an, um deine Kontakte in der Cloud zu speichern und auf allen Geräten zu synchronisieren.',
|
||||
'Im Demo-Modus kannst du die App erkunden. Melde dich an, um eigene Kontakte zu erstellen und zu speichern.',
|
||||
icon: 'cloud',
|
||||
},
|
||||
sync: {
|
||||
|
|
@ -33,7 +32,6 @@
|
|||
};
|
||||
|
||||
let currentMessage = $derived(messages[action]);
|
||||
let sessionContactCount = $derived(sessionContactsStore.count);
|
||||
|
||||
function handleLogin() {
|
||||
// Store return URL for redirect after login
|
||||
|
|
@ -121,20 +119,6 @@
|
|||
{currentMessage.description}
|
||||
</p>
|
||||
|
||||
<!-- Session contacts info -->
|
||||
{#if sessionContactCount > 0}
|
||||
<div class="bg-muted/50 mb-6 rounded-lg p-3 text-center text-sm">
|
||||
<span class="text-muted-foreground">
|
||||
Du hast <strong class="text-foreground">{sessionContactCount}</strong>
|
||||
{sessionContactCount === 1 ? 'Kontakt' : 'Kontakte'} in dieser Sitzung erstellt.
|
||||
</span>
|
||||
<br />
|
||||
<span class="text-muted-foreground text-xs">
|
||||
Diese werden nach der Anmeldung in deinen Account übernommen.
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
|
|
@ -159,8 +143,8 @@
|
|||
|
||||
<!-- Info text -->
|
||||
<p class="text-muted-foreground mt-4 text-center text-xs">
|
||||
Du kannst weiterhin Kontakte erstellen. Diese werden lokal gespeichert und gehen beim
|
||||
Schließen des Tabs verloren.
|
||||
Im Demo-Modus werden Beispielkontakte angezeigt. Melde dich an, um eigene Kontakte zu
|
||||
erstellen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { contactsApi, photoApi, type Contact } from '$lib/api/contacts';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import ContactNotes from './ContactNotes.svelte';
|
||||
import ContactTasks from './ContactTasks.svelte';
|
||||
import { ContactDetailSkeleton } from '$lib/components/skeletons';
|
||||
|
|
@ -134,6 +135,12 @@
|
|||
}
|
||||
|
||||
async function handleSave() {
|
||||
// Demo contact: show auth gate
|
||||
if (contactsStore.isDemoContact(contactId)) {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
error = null;
|
||||
try {
|
||||
|
|
@ -177,6 +184,12 @@
|
|||
}
|
||||
|
||||
async function handleDelete() {
|
||||
// Demo contact: show auth gate
|
||||
if (contactsStore.isDemoContact(contactId)) {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Kontakt wirklich löschen?')) return;
|
||||
deleting = true;
|
||||
try {
|
||||
|
|
@ -190,6 +203,13 @@
|
|||
|
||||
async function handleToggleFavorite() {
|
||||
if (!contact) return;
|
||||
|
||||
// Demo contact: show auth gate
|
||||
if (contactsStore.isDemoContact(contactId)) {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
contact = await contactsApi.toggleFavorite(contactId);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,7 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import ContactGridView from '$lib/components/views/ContactGridView.svelte';
|
||||
import ContactAlphabetView from '$lib/components/views/ContactAlphabetView.svelte';
|
||||
import ContactNetworkView from '$lib/components/views/ContactNetworkView.svelte';
|
||||
import {
|
||||
ContactListSkeleton,
|
||||
ContactGridSkeleton,
|
||||
NetworkGraphSkeleton,
|
||||
} from '$lib/components/skeletons';
|
||||
import { ContactListSkeleton, ContactGridSkeleton } from '$lib/components/skeletons';
|
||||
import { batchApi } from '$lib/api/batch';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
|
||||
|
|
@ -140,7 +135,12 @@
|
|||
|
||||
async function handleToggleFavorite(e: MouseEvent, id: string) {
|
||||
e.stopPropagation();
|
||||
await contactsStore.toggleFavorite(id);
|
||||
const result = await contactsStore.toggleFavorite(id);
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
}
|
||||
}
|
||||
|
||||
function handleContactClick(id: string) {
|
||||
|
|
@ -374,9 +374,7 @@
|
|||
|
||||
<!-- Loading state with skeleton -->
|
||||
{#if contactsStore.loading}
|
||||
{#if viewModeStore.mode === 'network'}
|
||||
<NetworkGraphSkeleton />
|
||||
{:else if viewModeStore.mode === 'grid'}
|
||||
{#if viewModeStore.mode === 'grid'}
|
||||
<ContactGridSkeleton count={8} />
|
||||
{:else}
|
||||
<ContactListSkeleton count={10} />
|
||||
|
|
@ -393,9 +391,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<!-- Contacts View -->
|
||||
{#if viewModeStore.mode === 'network'}
|
||||
<ContactNetworkView />
|
||||
{:else if viewModeStore.mode === 'grid'}
|
||||
{#if viewModeStore.mode === 'grid'}
|
||||
<ContactGridView
|
||||
contacts={sortedContacts}
|
||||
onContactClick={handleContactClick}
|
||||
|
|
@ -416,8 +412,8 @@
|
|||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Infinite scroll trigger & loading more indicator (not for network view) -->
|
||||
{#if viewModeStore.mode !== 'network' && contactsStore.hasMore}
|
||||
<!-- Infinite scroll trigger & loading more indicator -->
|
||||
{#if contactsStore.hasMore}
|
||||
<div bind:this={loadMoreTrigger} class="load-more-trigger">
|
||||
{#if contactsStore.loadingMore}
|
||||
<div class="loading-more">
|
||||
|
|
@ -428,13 +424,11 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Total count (not for network view) -->
|
||||
{#if viewModeStore.mode !== 'network'}
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
{contactsStore.contacts.length} / {contactsStore.total}
|
||||
{contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
|
||||
</p>
|
||||
{/if}
|
||||
<!-- Total count -->
|
||||
<p class="text-sm text-muted-foreground text-center">
|
||||
{contactsStore.contacts.length} / {contactsStore.total}
|
||||
{contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@
|
|||
import { _ } from 'svelte-i18n';
|
||||
import { onMount } from 'svelte';
|
||||
import { PillViewSwitcher, FilterDropdown, type FilterDropdownOption } from '@manacore/shared-ui';
|
||||
import { ZoomIn, ZoomOut, RotateCcw, Focus, X } from 'lucide-svelte';
|
||||
import { viewModeStore } from '$lib/stores/view-mode.svelte';
|
||||
import { X } from 'lucide-svelte';
|
||||
import { contactsFilterStore } from '$lib/stores/filter.svelte';
|
||||
import { networkStore } from '$lib/stores/network.svelte';
|
||||
import { tagsApi, type ContactTag, type Contact } from '$lib/api/contacts';
|
||||
import type { ContactFilter, BirthdayFilter } from '$lib/components/FilterBar.svelte';
|
||||
|
||||
|
|
@ -82,14 +80,6 @@
|
|||
contactsFilterStore.setSelectedCompany(null);
|
||||
}
|
||||
|
||||
// Network strength state
|
||||
let strengthValue = $state(networkStore.minStrength);
|
||||
|
||||
// Sync strength with store
|
||||
$effect(() => {
|
||||
strengthValue = networkStore.minStrength;
|
||||
});
|
||||
|
||||
// Sort options
|
||||
const sortOptions = [
|
||||
{ id: 'firstName', label: $_('sort.firstName'), title: $_('sort.firstName') },
|
||||
|
|
@ -100,173 +90,77 @@
|
|||
contactsFilterStore.setSortField(value as 'firstName' | 'lastName');
|
||||
}
|
||||
|
||||
function handleStrengthChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
strengthValue = parseInt(target.value, 10);
|
||||
networkStore.setMinStrength(strengthValue);
|
||||
}
|
||||
|
||||
function clearNetworkFilters() {
|
||||
strengthValue = 0;
|
||||
networkStore.clearFilters();
|
||||
}
|
||||
|
||||
const hasActiveNetworkFilters = $derived(networkStore.minStrength > 0);
|
||||
|
||||
// Check if in network mode
|
||||
const isNetworkMode = $derived(viewModeStore.mode === 'network');
|
||||
|
||||
onMount(() => {
|
||||
loadTags();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="toolbar-content-inner">
|
||||
{#if isNetworkMode}
|
||||
<!-- Network Mode Controls -->
|
||||
<!-- Filter Dropdowns -->
|
||||
<div class="filter-group">
|
||||
<!-- Tags Filter -->
|
||||
<FilterDropdown
|
||||
options={tagOptions}
|
||||
value={contactsFilterStore.selectedTagId}
|
||||
onChange={(v) => contactsFilterStore.setSelectedTagId(typeof v === 'string' ? v : null)}
|
||||
placeholder={$_('filters.allTags')}
|
||||
embedded={true}
|
||||
direction="up"
|
||||
/>
|
||||
|
||||
<!-- Strength Filter -->
|
||||
<div class="strength-group">
|
||||
<label for="network-strength-filter" class="strength-label">
|
||||
{$_('network.strength')}: {strengthValue}%
|
||||
</label>
|
||||
<input
|
||||
id="network-strength-filter"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="10"
|
||||
value={strengthValue}
|
||||
oninput={handleStrengthChange}
|
||||
class="strength-slider"
|
||||
title={$_('network.minStrength')}
|
||||
<!-- Contact Info Filter -->
|
||||
<FilterDropdown
|
||||
options={contactFilterOptions}
|
||||
value={contactsFilterStore.contactFilter}
|
||||
onChange={(v) =>
|
||||
contactsFilterStore.setContactFilter((typeof v === 'string' ? v : 'all') as ContactFilter)}
|
||||
placeholder={$_('filters.contact.all')}
|
||||
embedded={true}
|
||||
direction="up"
|
||||
/>
|
||||
|
||||
<!-- Birthday Filter -->
|
||||
<FilterDropdown
|
||||
options={birthdayFilterOptions}
|
||||
value={contactsFilterStore.birthdayFilter}
|
||||
onChange={(v) =>
|
||||
contactsFilterStore.setBirthdayFilter(
|
||||
(typeof v === 'string' ? v : 'all') as BirthdayFilter
|
||||
)}
|
||||
placeholder={$_('filters.birthday.all')}
|
||||
embedded={true}
|
||||
direction="up"
|
||||
/>
|
||||
|
||||
<!-- Company Filter (only if companies exist) -->
|
||||
{#if companyOptions.length > 0}
|
||||
<FilterDropdown
|
||||
options={companyOptions}
|
||||
value={contactsFilterStore.selectedCompany}
|
||||
onChange={(v) => contactsFilterStore.setSelectedCompany(typeof v === 'string' ? v : null)}
|
||||
placeholder={$_('filters.allCompanies')}
|
||||
embedded={true}
|
||||
direction="up"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- Zoom Controls -->
|
||||
<div class="zoom-controls">
|
||||
<button
|
||||
onclick={() => networkStore.zoomIn()}
|
||||
class="control-btn"
|
||||
aria-label={$_('network.zoomIn')}
|
||||
title={$_('network.zoomIn')}
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => networkStore.zoomOut()}
|
||||
class="control-btn"
|
||||
aria-label={$_('network.zoomOut')}
|
||||
title={$_('network.zoomOut')}
|
||||
>
|
||||
<ZoomOut size={16} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => networkStore.resetZoom()}
|
||||
class="control-btn"
|
||||
aria-label={$_('network.resetZoom')}
|
||||
title={$_('network.resetZoom')}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => networkStore.focusOnSelected()}
|
||||
class="control-btn"
|
||||
aria-label={$_('network.focusSelected')}
|
||||
title={$_('network.focusSelected')}
|
||||
>
|
||||
<Focus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Clear Filters -->
|
||||
{#if hasActiveNetworkFilters}
|
||||
<div class="toolbar-divider"></div>
|
||||
<button onclick={clearNetworkFilters} class="clear-btn" title={$_('common.clearFilters')}>
|
||||
<X size={14} />
|
||||
<span>{$_('common.clearFilters')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats">
|
||||
<span class="stat">{networkStore.nodes.length} {$_('contacts.contactsPlural')}</span>
|
||||
<span class="stat-divider">•</span>
|
||||
<span class="stat">{networkStore.links.length} {$_('network.connections')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Standard Mode Controls (Grid/Alphabet) -->
|
||||
<!-- Clear Filters Button -->
|
||||
{#if activeFilterCount > 0}
|
||||
<button onclick={clearAllFilters} class="clear-btn" title={$_('filters.clearAll')}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Filter Dropdowns -->
|
||||
<div class="filter-group">
|
||||
<!-- Tags Filter -->
|
||||
<FilterDropdown
|
||||
options={tagOptions}
|
||||
value={contactsFilterStore.selectedTagId}
|
||||
onChange={(v) => contactsFilterStore.setSelectedTagId(typeof v === 'string' ? v : null)}
|
||||
placeholder={$_('filters.allTags')}
|
||||
embedded={true}
|
||||
direction="up"
|
||||
/>
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- Contact Info Filter -->
|
||||
<FilterDropdown
|
||||
options={contactFilterOptions}
|
||||
value={contactsFilterStore.contactFilter}
|
||||
onChange={(v) =>
|
||||
contactsFilterStore.setContactFilter(
|
||||
(typeof v === 'string' ? v : 'all') as ContactFilter
|
||||
)}
|
||||
placeholder={$_('filters.contact.all')}
|
||||
embedded={true}
|
||||
direction="up"
|
||||
/>
|
||||
|
||||
<!-- Birthday Filter -->
|
||||
<FilterDropdown
|
||||
options={birthdayFilterOptions}
|
||||
value={contactsFilterStore.birthdayFilter}
|
||||
onChange={(v) =>
|
||||
contactsFilterStore.setBirthdayFilter(
|
||||
(typeof v === 'string' ? v : 'all') as BirthdayFilter
|
||||
)}
|
||||
placeholder={$_('filters.birthday.all')}
|
||||
embedded={true}
|
||||
direction="up"
|
||||
/>
|
||||
|
||||
<!-- Company Filter (only if companies exist) -->
|
||||
{#if companyOptions.length > 0}
|
||||
<FilterDropdown
|
||||
options={companyOptions}
|
||||
value={contactsFilterStore.selectedCompany}
|
||||
onChange={(v) => contactsFilterStore.setSelectedCompany(typeof v === 'string' ? v : null)}
|
||||
placeholder={$_('filters.allCompanies')}
|
||||
embedded={true}
|
||||
direction="up"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Clear Filters Button -->
|
||||
{#if activeFilterCount > 0}
|
||||
<button onclick={clearAllFilters} class="clear-btn" title={$_('filters.clearAll')}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- Sort Toggle -->
|
||||
<PillViewSwitcher
|
||||
options={sortOptions}
|
||||
value={contactsFilterStore.sortField}
|
||||
onChange={handleSortChange}
|
||||
embedded={true}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Sort Toggle -->
|
||||
<PillViewSwitcher
|
||||
options={sortOptions}
|
||||
value={contactsFilterStore.sortField}
|
||||
onChange={handleSortChange}
|
||||
embedded={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -284,79 +178,6 @@
|
|||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
/* Network-specific styles */
|
||||
.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;
|
||||
|
|
@ -375,18 +196,6 @@
|
|||
background: hsl(var(--destructive) / 0.15);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Filter Group */
|
||||
.filter-group {
|
||||
display: flex;
|
||||
|
|
@ -394,19 +203,4 @@
|
|||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.strength-group {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.strength-slider {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { contactsApi, photoApi } from '$lib/api/contacts';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
|
||||
import SocialMediaFields from './forms/SocialMediaFields.svelte';
|
||||
import DateFields from './forms/DateFields.svelte';
|
||||
|
|
@ -119,6 +120,12 @@
|
|||
}
|
||||
|
||||
async function handleSave() {
|
||||
// Demo mode: show auth gate
|
||||
if (!authStore.isAuthenticated) {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firstName && !lastName && !email) {
|
||||
error = 'Bitte mindestens Name oder E-Mail angeben';
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,374 +0,0 @@
|
|||
<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) / 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);
|
||||
}
|
||||
|
||||
@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>
|
||||
|
|
@ -1,492 +0,0 @@
|
|||
<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 (uses subtitle field) -->
|
||||
{#if node.subtitle}
|
||||
<text
|
||||
y={isSelected ? 56 : 50}
|
||||
class="node-company"
|
||||
text-anchor="middle"
|
||||
font-size="9"
|
||||
>
|
||||
{node.subtitle}
|
||||
</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>
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { networkStore, type SimulationNode } from '$lib/stores/network.svelte';
|
||||
import { contactsFilterStore } from '$lib/stores/filter.svelte';
|
||||
import { NetworkGraph } from '@manacore/shared-ui';
|
||||
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
|
||||
import { NetworkGraphSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
// Sync global search to network store
|
||||
$effect(() => {
|
||||
networkStore.setSearch(contactsFilterStore.searchQuery);
|
||||
});
|
||||
|
||||
// Sync tag filter to network store
|
||||
$effect(() => {
|
||||
networkStore.setFilterTag(contactsFilterStore.selectedTagId);
|
||||
});
|
||||
|
||||
// Sync company filter to network store
|
||||
$effect(() => {
|
||||
networkStore.setFilterCompany(contactsFilterStore.selectedCompany);
|
||||
});
|
||||
|
||||
// Refocus view when search results change
|
||||
let previousNodeCount = $state(0);
|
||||
$effect(() => {
|
||||
const currentNodeCount = networkStore.nodes.length;
|
||||
const hasSearch = contactsFilterStore.searchQuery.length > 0;
|
||||
|
||||
// If search is active and node count changed, reset zoom to show all results
|
||||
if (hasSearch && currentNodeCount !== previousNodeCount && currentNodeCount > 0) {
|
||||
setTimeout(() => {
|
||||
graphComponent?.resetZoom();
|
||||
}, 100);
|
||||
}
|
||||
previousNodeCount = currentNodeCount;
|
||||
});
|
||||
|
||||
let graphComponent: NetworkGraph;
|
||||
let graphContainer: HTMLDivElement;
|
||||
|
||||
function handleNodeClick(node: SimulationNode) {
|
||||
// Select node (highlight connections and show detail sidebar)
|
||||
networkStore.selectNode(node.id);
|
||||
}
|
||||
|
||||
function handleNodeDoubleClick(node: SimulationNode) {
|
||||
// Navigate to contact detail page
|
||||
goto(`/contacts/${node.id}`);
|
||||
}
|
||||
|
||||
function handleBackgroundClick() {
|
||||
networkStore.selectNode(null);
|
||||
}
|
||||
|
||||
function handleCloseSidebar() {
|
||||
networkStore.selectNode(null);
|
||||
}
|
||||
|
||||
function handleDragStart(node: SimulationNode) {
|
||||
networkStore.fixNode(node.id, node.x ?? 0, node.y ?? 0);
|
||||
networkStore.reheatSimulation();
|
||||
}
|
||||
|
||||
function handleDrag(node: SimulationNode, x: number, y: number) {
|
||||
networkStore.fixNode(node.id, x, y);
|
||||
}
|
||||
|
||||
function handleDragEnd(node: SimulationNode) {
|
||||
networkStore.releaseNode(node.id);
|
||||
}
|
||||
|
||||
// Register graph component with store when it changes
|
||||
$effect(() => {
|
||||
networkStore.setGraphComponent(graphComponent);
|
||||
});
|
||||
|
||||
// Initialize simulation when data is loaded and container is ready
|
||||
$effect(() => {
|
||||
if (!networkStore.loading && networkStore.allNodes.length > 0 && graphContainer) {
|
||||
const rect = graphContainer.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
networkStore.initSimulation(rect.width, rect.height);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
networkStore.loadGraph();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
networkStore.setGraphComponent(null);
|
||||
networkStore.stopSimulation();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="network-view">
|
||||
<!-- Error Banner -->
|
||||
{#if networkStore.error}
|
||||
<div class="error-banner" role="alert">
|
||||
<svg class="error-icon" 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}
|
||||
|
||||
<!-- Graph Container -->
|
||||
<div class="graph-container" bind:this={graphContainer}>
|
||||
{#if networkStore.loading}
|
||||
<NetworkGraphSkeleton />
|
||||
{:else}
|
||||
<NetworkGraph
|
||||
bind:this={graphComponent}
|
||||
nodes={networkStore.nodes}
|
||||
links={networkStore.links}
|
||||
selectedNodeId={networkStore.selectedNodeId}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onBackgroundClick={handleBackgroundClick}
|
||||
onDragStart={handleDragStart}
|
||||
onDrag={handleDrag}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Contact Detail Modal as Sidebar -->
|
||||
{#if networkStore.selectedNodeId}
|
||||
<div class="modal-sidebar-wrapper">
|
||||
<ContactDetailModal contactId={networkStore.selectedNodeId} onClose={handleCloseSidebar} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.network-view {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Error Banner */
|
||||
.error-banner {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
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);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Graph Container - Full screen */
|
||||
.graph-container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Modal Sidebar Wrapper - Override modal positioning */
|
||||
.modal-sidebar-wrapper {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
bottom: calc(200px + env(safe-area-inset-bottom));
|
||||
width: 400px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Override the modal styles when inside the sidebar wrapper */
|
||||
.modal-sidebar-wrapper :global(.modal-backdrop) {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modal-sidebar-wrapper :global(.modal-container) {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--card) / 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
animation: slideInRight 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.modal-sidebar-wrapper {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
top: auto;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 70vh;
|
||||
}
|
||||
|
||||
.modal-sidebar-wrapper :global(.modal-container) {
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
animation: slideInUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
215
apps/contacts/apps/web/src/lib/data/demo-contacts.ts
Normal file
215
apps/contacts/apps/web/src/lib/data/demo-contacts.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* Demo Contacts - Static sample contacts for unauthenticated users
|
||||
*
|
||||
* Shows a realistic contact list with various contact types to demonstrate
|
||||
* the app's capabilities without requiring login.
|
||||
*/
|
||||
|
||||
import type { Contact } from '$lib/api/contacts';
|
||||
|
||||
/**
|
||||
* Generate demo contacts
|
||||
*/
|
||||
export function generateDemoContacts(): Contact[] {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const demoContacts: Contact[] = [
|
||||
{
|
||||
id: 'demo_1',
|
||||
userId: 'demo',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Müller',
|
||||
displayName: 'Anna Müller',
|
||||
email: 'anna.mueller@example.com',
|
||||
phone: '+49 30 12345678',
|
||||
mobile: '+49 170 1234567',
|
||||
company: 'Tech Solutions GmbH',
|
||||
jobTitle: 'Product Manager',
|
||||
city: 'Berlin',
|
||||
country: 'Deutschland',
|
||||
isFavorite: true,
|
||||
isArchived: false,
|
||||
visibility: 'private',
|
||||
tags: [{ id: 'tag_1', name: 'Arbeit', color: '#3b82f6' }],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'demo_2',
|
||||
userId: 'demo',
|
||||
firstName: 'Max',
|
||||
lastName: 'Schmidt',
|
||||
displayName: 'Max Schmidt',
|
||||
email: 'max.schmidt@example.com',
|
||||
mobile: '+49 171 9876543',
|
||||
company: 'Design Studio',
|
||||
jobTitle: 'UX Designer',
|
||||
city: 'München',
|
||||
country: 'Deutschland',
|
||||
isFavorite: true,
|
||||
isArchived: false,
|
||||
visibility: 'private',
|
||||
tags: [{ id: 'tag_1', name: 'Arbeit', color: '#3b82f6' }],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'demo_3',
|
||||
userId: 'demo',
|
||||
firstName: 'Lisa',
|
||||
lastName: 'Weber',
|
||||
displayName: 'Lisa Weber',
|
||||
email: 'lisa.w@example.com',
|
||||
phone: '+49 40 87654321',
|
||||
city: 'Hamburg',
|
||||
country: 'Deutschland',
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
visibility: 'private',
|
||||
tags: [{ id: 'tag_2', name: 'Freunde', color: '#22c55e' }],
|
||||
birthday: '1992-03-15',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'demo_4',
|
||||
userId: 'demo',
|
||||
firstName: 'Thomas',
|
||||
lastName: 'Becker',
|
||||
displayName: 'Thomas Becker',
|
||||
email: 'thomas.becker@example.com',
|
||||
mobile: '+49 172 5555555',
|
||||
company: 'Consulting Partners',
|
||||
jobTitle: 'Senior Consultant',
|
||||
city: 'Frankfurt',
|
||||
country: 'Deutschland',
|
||||
website: 'https://example.com',
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
visibility: 'private',
|
||||
tags: [{ id: 'tag_1', name: 'Arbeit', color: '#3b82f6' }],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'demo_5',
|
||||
userId: 'demo',
|
||||
firstName: 'Sarah',
|
||||
lastName: 'Klein',
|
||||
displayName: 'Sarah Klein',
|
||||
email: 'sarah.klein@example.com',
|
||||
phone: '+49 221 1111111',
|
||||
mobile: '+49 173 2222222',
|
||||
city: 'Köln',
|
||||
country: 'Deutschland',
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
visibility: 'private',
|
||||
tags: [{ id: 'tag_3', name: 'Familie', color: '#f59e0b' }],
|
||||
birthday: '1988-07-22',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'demo_6',
|
||||
userId: 'demo',
|
||||
firstName: 'Michael',
|
||||
lastName: 'Hoffmann',
|
||||
displayName: 'Michael Hoffmann',
|
||||
email: 'm.hoffmann@example.com',
|
||||
company: 'Startup Ventures',
|
||||
jobTitle: 'CEO',
|
||||
city: 'Berlin',
|
||||
country: 'Deutschland',
|
||||
linkedin: 'michael-hoffmann',
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
visibility: 'private',
|
||||
tags: [{ id: 'tag_1', name: 'Arbeit', color: '#3b82f6' }],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'demo_7',
|
||||
userId: 'demo',
|
||||
firstName: 'Julia',
|
||||
lastName: 'Fischer',
|
||||
displayName: 'Julia Fischer',
|
||||
mobile: '+49 174 3333333',
|
||||
city: 'Stuttgart',
|
||||
country: 'Deutschland',
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
visibility: 'private',
|
||||
tags: [{ id: 'tag_2', name: 'Freunde', color: '#22c55e' }],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'demo_8',
|
||||
userId: 'demo',
|
||||
firstName: 'Dr. Stefan',
|
||||
lastName: 'Wagner',
|
||||
displayName: 'Dr. Stefan Wagner',
|
||||
email: 'dr.wagner@praxis.de',
|
||||
phone: '+49 89 4444444',
|
||||
company: 'Praxis Dr. Wagner',
|
||||
jobTitle: 'Arzt',
|
||||
street: 'Hauptstraße 42',
|
||||
city: 'München',
|
||||
postalCode: '80331',
|
||||
country: 'Deutschland',
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
visibility: 'private',
|
||||
notes: 'Hausarzt, Termine immer vormittags',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'demo_9',
|
||||
userId: 'demo',
|
||||
firstName: 'Emma',
|
||||
lastName: 'Braun',
|
||||
displayName: 'Emma Braun',
|
||||
email: 'emma.b@example.com',
|
||||
mobile: '+49 175 6666666',
|
||||
company: 'Marketing Pro',
|
||||
jobTitle: 'Marketing Director',
|
||||
city: 'Düsseldorf',
|
||||
country: 'Deutschland',
|
||||
isFavorite: true,
|
||||
isArchived: false,
|
||||
visibility: 'private',
|
||||
tags: [{ id: 'tag_1', name: 'Arbeit', color: '#3b82f6' }],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'demo_10',
|
||||
userId: 'demo',
|
||||
firstName: 'Peter',
|
||||
lastName: 'Schneider',
|
||||
displayName: 'Peter Schneider',
|
||||
email: 'peter.schneider@example.com',
|
||||
phone: '+49 511 7777777',
|
||||
city: 'Hannover',
|
||||
country: 'Deutschland',
|
||||
isFavorite: false,
|
||||
isArchived: true,
|
||||
visibility: 'private',
|
||||
tags: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
|
||||
return demoContacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a contact ID is a demo contact
|
||||
*/
|
||||
export function isDemoContact(id: string): boolean {
|
||||
return id.startsWith('demo_');
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
/**
|
||||
* Contacts Store - Manages contacts state using Svelte 5 runes
|
||||
* Authenticated users: contacts from API
|
||||
* Demo mode: static sample contacts to showcase the app
|
||||
*/
|
||||
|
||||
import { contactsApi } from '$lib/api/contacts';
|
||||
import type { Contact, ContactFilters } from '$lib/api/contacts';
|
||||
import { authStore } from './auth.svelte';
|
||||
import { generateDemoContacts, isDemoContact } from '$lib/data/demo-contacts';
|
||||
|
||||
// Default page size for pagination
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
|
|
@ -48,6 +52,7 @@ export const contactsStore = {
|
|||
|
||||
/**
|
||||
* Load contacts with optional filters (resets to first page)
|
||||
* In demo mode, loads static sample contacts
|
||||
*/
|
||||
async loadContacts(newFilters?: ContactFilters) {
|
||||
if (newFilters) {
|
||||
|
|
@ -58,6 +63,35 @@ export const contactsStore = {
|
|||
error = null;
|
||||
currentOffset = 0;
|
||||
|
||||
// Demo mode: load static demo contacts
|
||||
if (!authStore.isAuthenticated) {
|
||||
let demoContacts = generateDemoContacts();
|
||||
|
||||
// Apply filters to demo contacts
|
||||
if (filters.search) {
|
||||
const search = filters.search.toLowerCase();
|
||||
demoContacts = demoContacts.filter(
|
||||
(c) =>
|
||||
c.displayName?.toLowerCase().includes(search) ||
|
||||
c.email?.toLowerCase().includes(search) ||
|
||||
c.company?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
if (filters.isFavorite !== undefined) {
|
||||
demoContacts = demoContacts.filter((c) => c.isFavorite === filters.isFavorite);
|
||||
}
|
||||
if (filters.isArchived !== undefined) {
|
||||
demoContacts = demoContacts.filter((c) => c.isArchived === filters.isArchived);
|
||||
}
|
||||
|
||||
contacts = demoContacts;
|
||||
total = demoContacts.length;
|
||||
hasMore = false;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticated: fetch from API
|
||||
try {
|
||||
const result = await contactsApi.list({
|
||||
...filters,
|
||||
|
|
@ -127,8 +161,14 @@ export const contactsStore = {
|
|||
|
||||
/**
|
||||
* Create a new contact
|
||||
* Requires authentication - demo mode shows auth gate
|
||||
*/
|
||||
async createContact(data: Partial<Contact>) {
|
||||
// Demo mode: require authentication
|
||||
if (!authStore.isAuthenticated) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
|
|
@ -149,8 +189,14 @@ export const contactsStore = {
|
|||
|
||||
/**
|
||||
* Update a contact
|
||||
* Demo contacts require authentication
|
||||
*/
|
||||
async updateContact(id: string, data: Partial<Contact>) {
|
||||
// Demo contact: require authentication
|
||||
if (isDemoContact(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
|
|
@ -173,8 +219,14 @@ export const contactsStore = {
|
|||
|
||||
/**
|
||||
* Delete a contact
|
||||
* Demo contacts require authentication
|
||||
*/
|
||||
async deleteContact(id: string) {
|
||||
// Demo contact: require authentication
|
||||
if (isDemoContact(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
|
|
@ -197,8 +249,14 @@ export const contactsStore = {
|
|||
|
||||
/**
|
||||
* Toggle favorite status
|
||||
* Demo contacts require authentication
|
||||
*/
|
||||
async toggleFavorite(id: string) {
|
||||
// Demo contact: require authentication
|
||||
if (isDemoContact(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
try {
|
||||
const contact = await contactsApi.toggleFavorite(id);
|
||||
// Update in local state
|
||||
|
|
@ -215,8 +273,14 @@ export const contactsStore = {
|
|||
|
||||
/**
|
||||
* Toggle archive status
|
||||
* Demo contacts require authentication
|
||||
*/
|
||||
async toggleArchive(id: string) {
|
||||
// Demo contact: require authentication
|
||||
if (isDemoContact(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
try {
|
||||
const contact = await contactsApi.toggleArchive(id);
|
||||
// Remove from current view if archived/unarchived
|
||||
|
|
@ -260,4 +324,11 @@ export const contactsStore = {
|
|||
clearSelected() {
|
||||
selectedContact = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a contact is a demo contact (static sample data)
|
||||
*/
|
||||
isDemoContact(contactId: string) {
|
||||
return isDemoContact(contactId);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,539 +0,0 @@
|
|||
/**
|
||||
* 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,
|
||||
} from 'd3-force';
|
||||
import type {
|
||||
SimulationNode as SharedSimulationNode,
|
||||
SimulationLink as SharedSimulationLink,
|
||||
} from '@manacore/shared-ui';
|
||||
|
||||
// Re-export types from shared-ui for convenience
|
||||
export type SimulationNode = SharedSimulationNode;
|
||||
export type SimulationLink = SharedSimulationLink;
|
||||
|
||||
// Interface for NetworkGraph component zoom methods
|
||||
interface NetworkGraphZoomMethods {
|
||||
zoomIn(): void;
|
||||
zoomOut(): void;
|
||||
resetZoom(): void;
|
||||
focusOnSelectedNode(): void;
|
||||
}
|
||||
|
||||
// Graph component reference for zoom controls
|
||||
let graphComponentRef: NetworkGraphZoomMethods | 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[]>([]);
|
||||
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 minStrength = $state(0);
|
||||
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(() => {
|
||||
let result = nodes;
|
||||
|
||||
// Search filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(node) =>
|
||||
node.name.toLowerCase().includes(query) ||
|
||||
node.subtitle?.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 (uses subtitle field)
|
||||
if (filterCompany) {
|
||||
result = result.filter((node) => node.subtitle === 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;
|
||||
// Check if both nodes are visible and strength meets minimum
|
||||
if (!filteredNodeIds.has(sourceId) || !filteredNodeIds.has(targetId)) {
|
||||
return false;
|
||||
}
|
||||
// Filter by minimum strength
|
||||
if (minStrength > 0 && link.strength < minStrength) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// Get unique companies for filter dropdown (uses subtitle field)
|
||||
const uniqueCompanies = $derived.by(() => {
|
||||
const companies = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
if (node.subtitle) {
|
||||
companies.add(node.subtitle);
|
||||
}
|
||||
}
|
||||
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 minStrength() {
|
||||
return minStrength;
|
||||
},
|
||||
get uniqueCompanies() {
|
||||
return uniqueCompanies;
|
||||
},
|
||||
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: NetworkGraphZoomMethods | 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
|
||||
*/
|
||||
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 with subtitle for company
|
||||
nodes = response.nodes.map((node) => ({
|
||||
...node,
|
||||
subtitle: node.company, // Map company to subtitle for shared component
|
||||
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;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set minimum strength filter
|
||||
*/
|
||||
setMinStrength(strength: number) {
|
||||
minStrength = strength;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all filters
|
||||
*/
|
||||
clearFilters() {
|
||||
searchQuery = '';
|
||||
filterTagId = null;
|
||||
filterCompany = null;
|
||||
minStrength = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
/**
|
||||
* Session Contacts Store - Temporary local contacts for guest users
|
||||
* Contacts are stored in sessionStorage and lost when the browser tab is closed
|
||||
*/
|
||||
|
||||
import type { Contact } from '$lib/api/contacts';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const STORAGE_KEY = 'contacts-session-contacts';
|
||||
|
||||
// Generate a unique ID for session contacts
|
||||
function generateSessionId(): string {
|
||||
return `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
// Load contacts from sessionStorage
|
||||
function loadFromStorage(): Contact[] {
|
||||
if (!browser) return [];
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Save contacts to sessionStorage
|
||||
function saveToStorage(contacts: Contact[]) {
|
||||
if (!browser) return;
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(contacts));
|
||||
} catch (e) {
|
||||
console.warn('Failed to save session contacts:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// State
|
||||
let contacts = $state<Contact[]>(loadFromStorage());
|
||||
|
||||
export const sessionContactsStore = {
|
||||
get contacts() {
|
||||
return contacts;
|
||||
},
|
||||
|
||||
get hasContacts() {
|
||||
return contacts.length > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize from sessionStorage (call on mount)
|
||||
*/
|
||||
initialize() {
|
||||
contacts = loadFromStorage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new session contact
|
||||
*/
|
||||
createContact(data: Partial<Contact>): Contact {
|
||||
const now = new Date().toISOString();
|
||||
const newContact: Contact = {
|
||||
id: generateSessionId(),
|
||||
userId: 'guest',
|
||||
firstName: data.firstName || null,
|
||||
lastName: data.lastName || null,
|
||||
displayName: data.displayName || null,
|
||||
nickname: data.nickname || null,
|
||||
email: data.email || null,
|
||||
phone: data.phone || null,
|
||||
mobile: data.mobile || null,
|
||||
street: data.street || null,
|
||||
city: data.city || null,
|
||||
postalCode: data.postalCode || null,
|
||||
country: data.country || null,
|
||||
company: data.company || null,
|
||||
jobTitle: data.jobTitle || null,
|
||||
department: data.department || null,
|
||||
website: data.website || null,
|
||||
birthday: data.birthday || null,
|
||||
notes: data.notes || null,
|
||||
photoUrl: data.photoUrl || null,
|
||||
customDates: data.customDates || null,
|
||||
linkedin: data.linkedin || null,
|
||||
twitter: data.twitter || null,
|
||||
facebook: data.facebook || null,
|
||||
instagram: data.instagram || null,
|
||||
xing: data.xing || null,
|
||||
github: data.github || null,
|
||||
youtube: data.youtube || null,
|
||||
tiktok: data.tiktok || null,
|
||||
telegram: data.telegram || null,
|
||||
whatsapp: data.whatsapp || null,
|
||||
signal: data.signal || null,
|
||||
discord: data.discord || null,
|
||||
bluesky: data.bluesky || null,
|
||||
tags: [],
|
||||
isFavorite: data.isFavorite || false,
|
||||
isArchived: data.isArchived || false,
|
||||
organizationId: null,
|
||||
teamId: null,
|
||||
visibility: 'private',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
contacts = [...contacts, newContact];
|
||||
saveToStorage(contacts);
|
||||
return newContact;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a session contact
|
||||
*/
|
||||
updateContact(id: string, data: Partial<Contact>): Contact | null {
|
||||
const index = contacts.findIndex((c) => c.id === id);
|
||||
if (index === -1) return null;
|
||||
|
||||
const updatedContact = {
|
||||
...contacts[index],
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
contacts = contacts.map((c) => (c.id === id ? updatedContact : c));
|
||||
saveToStorage(contacts);
|
||||
return updatedContact;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle favorite status
|
||||
*/
|
||||
toggleFavorite(id: string): Contact | null {
|
||||
const contact = contacts.find((c) => c.id === id);
|
||||
if (!contact) return null;
|
||||
return this.updateContact(id, { isFavorite: !contact.isFavorite });
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle archive status
|
||||
*/
|
||||
toggleArchive(id: string): Contact | null {
|
||||
const contact = contacts.find((c) => c.id === id);
|
||||
if (!contact) return null;
|
||||
return this.updateContact(id, { isArchived: !contact.isArchived });
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a session contact
|
||||
*/
|
||||
deleteContact(id: string): boolean {
|
||||
const hadContact = contacts.some((c) => c.id === id);
|
||||
contacts = contacts.filter((c) => c.id !== id);
|
||||
saveToStorage(contacts);
|
||||
return hadContact;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get contact by ID
|
||||
*/
|
||||
getById(id: string): Contact | undefined {
|
||||
return contacts.find((c) => c.id === id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a contact ID is a session contact
|
||||
*/
|
||||
isSessionContact(id: string): boolean {
|
||||
return id.startsWith('session_');
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all contacts (for migration to cloud on login)
|
||||
*/
|
||||
getAllContacts(): Contact[] {
|
||||
return [...contacts];
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all session contacts (after migration or on explicit clear)
|
||||
*/
|
||||
clear() {
|
||||
contacts = [];
|
||||
if (browser) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get count of session contacts
|
||||
*/
|
||||
get count() {
|
||||
return contacts.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get favorite contacts
|
||||
*/
|
||||
get favoriteContacts() {
|
||||
return contacts.filter((c) => c.isFavorite && !c.isArchived);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get archived contacts
|
||||
*/
|
||||
get archivedContacts() {
|
||||
return contacts.filter((c) => c.isArchived);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get active (non-archived) contacts
|
||||
*/
|
||||
get activeContacts() {
|
||||
return contacts.filter((c) => !c.isArchived);
|
||||
},
|
||||
|
||||
/**
|
||||
* Search session contacts
|
||||
*/
|
||||
search(query: string): Contact[] {
|
||||
if (!query.trim()) return this.activeContacts;
|
||||
const lower = query.toLowerCase();
|
||||
return this.activeContacts.filter((c) => {
|
||||
const searchFields = [
|
||||
c.firstName,
|
||||
c.lastName,
|
||||
c.displayName,
|
||||
c.email,
|
||||
c.phone,
|
||||
c.mobile,
|
||||
c.company,
|
||||
];
|
||||
return searchFields.some((field) => field?.toLowerCase().includes(lower));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,275 +0,0 @@
|
|||
/**
|
||||
* Contacts Statistics Store - Calculates contact statistics using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { Contact } from '$lib/api/contacts';
|
||||
import { subDays, format, parseISO, isWithinInterval, getMonth, eachDayOfInterval } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import type {
|
||||
HeatmapDataPoint,
|
||||
TrendDataPoint,
|
||||
DonutSegment,
|
||||
ProgressItem,
|
||||
} from '@manacore/shared-ui';
|
||||
|
||||
// Types
|
||||
export interface ContactTag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// State
|
||||
let contacts = $state<Contact[]>([]);
|
||||
let tags = $state<ContactTag[]>([]);
|
||||
|
||||
export const contactsStatisticsStore = {
|
||||
// Setters
|
||||
setContacts(newContacts: Contact[]) {
|
||||
contacts = newContacts;
|
||||
},
|
||||
|
||||
setTags(newTags: ContactTag[]) {
|
||||
tags = newTags;
|
||||
},
|
||||
|
||||
// Quick Stats
|
||||
get totalContacts() {
|
||||
return contacts.length;
|
||||
},
|
||||
|
||||
get favoriteContacts() {
|
||||
return contacts.filter((c) => c.isFavorite).length;
|
||||
},
|
||||
|
||||
get archivedContacts() {
|
||||
return contacts.filter((c) => c.isArchived).length;
|
||||
},
|
||||
|
||||
get activeContacts() {
|
||||
return contacts.filter((c) => !c.isArchived).length;
|
||||
},
|
||||
|
||||
get recentlyAdded() {
|
||||
const weekAgo = subDays(new Date(), 7);
|
||||
return contacts.filter((c) => {
|
||||
const createdAt =
|
||||
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
|
||||
return createdAt >= weekAgo;
|
||||
}).length;
|
||||
},
|
||||
|
||||
get birthdaysThisMonth() {
|
||||
const currentMonth = getMonth(new Date());
|
||||
return contacts.filter((c) => {
|
||||
if (!c.birthday) return false;
|
||||
const birthday = typeof c.birthday === 'string' ? parseISO(c.birthday) : new Date(c.birthday);
|
||||
return getMonth(birthday) === currentMonth;
|
||||
}).length;
|
||||
},
|
||||
|
||||
get contactsWithEmail() {
|
||||
return contacts.filter((c) => c.email).length;
|
||||
},
|
||||
|
||||
get contactsWithPhone() {
|
||||
return contacts.filter((c) => c.phone || c.mobile).length;
|
||||
},
|
||||
|
||||
// Completeness rate (contacts with email AND phone)
|
||||
get completenessRate() {
|
||||
if (contacts.length === 0) return 0;
|
||||
const complete = contacts.filter((c) => c.email && (c.phone || c.mobile)).length;
|
||||
return Math.round((complete / contacts.length) * 100);
|
||||
},
|
||||
|
||||
// Activity Heatmap (last 6 months) - based on contact creation
|
||||
get activityHeatmap(): HeatmapDataPoint[] {
|
||||
const endDate = new Date();
|
||||
const startDate = subDays(endDate, 180);
|
||||
|
||||
// Count contacts created per day
|
||||
const creationMap = new Map<string, number>();
|
||||
|
||||
contacts.forEach((c) => {
|
||||
const createdAt =
|
||||
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
|
||||
if (createdAt >= startDate && createdAt <= endDate) {
|
||||
const dateKey = format(createdAt, 'yyyy-MM-dd');
|
||||
creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate all days
|
||||
const days = eachDayOfInterval({ start: startDate, end: endDate });
|
||||
|
||||
return days.map((day) => {
|
||||
const dateKey = format(day, 'yyyy-MM-dd');
|
||||
return {
|
||||
date: dateKey,
|
||||
count: creationMap.get(dateKey) || 0,
|
||||
dayOfWeek: day.getDay(),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Weekly Trend (last 4 weeks)
|
||||
get weeklyTrend(): TrendDataPoint[] {
|
||||
const endDate = new Date();
|
||||
const startDate = subDays(endDate, 27);
|
||||
|
||||
const creationMap = new Map<string, number>();
|
||||
|
||||
contacts.forEach((c) => {
|
||||
const createdAt =
|
||||
typeof c.createdAt === 'string' ? parseISO(c.createdAt) : new Date(c.createdAt);
|
||||
if (createdAt >= startDate && createdAt <= endDate) {
|
||||
const dateKey = format(createdAt, 'yyyy-MM-dd');
|
||||
creationMap.set(dateKey, (creationMap.get(dateKey) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const days = eachDayOfInterval({ start: startDate, end: endDate });
|
||||
|
||||
return days.map((day) => {
|
||||
const dateKey = format(day, 'yyyy-MM-dd');
|
||||
return {
|
||||
date: dateKey,
|
||||
count: creationMap.get(dateKey) || 0,
|
||||
label: format(day, 'EEE', { locale: de }),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Contact Status Breakdown (Donut Chart) - Favorites / Active / Archived
|
||||
get statusBreakdown(): DonutSegment[] {
|
||||
const total = contacts.length;
|
||||
if (total === 0) return [];
|
||||
|
||||
const favorites = contacts.filter((c) => c.isFavorite && !c.isArchived).length;
|
||||
const archived = contacts.filter((c) => c.isArchived).length;
|
||||
const regular = contacts.filter((c) => !c.isFavorite && !c.isArchived).length;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'favorites',
|
||||
label: 'Favoriten',
|
||||
count: favorites,
|
||||
percentage: Math.round((favorites / total) * 100),
|
||||
color: '#F59E0B', // amber
|
||||
},
|
||||
{
|
||||
id: 'regular',
|
||||
label: 'Aktiv',
|
||||
count: regular,
|
||||
percentage: Math.round((regular / total) * 100),
|
||||
color: '#10B981', // green
|
||||
},
|
||||
{
|
||||
id: 'archived',
|
||||
label: 'Archiviert',
|
||||
count: archived,
|
||||
percentage: Math.round((archived / total) * 100),
|
||||
color: '#6B7280', // gray
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// Tags Progress (Progress Bars)
|
||||
get tagProgress(): ProgressItem[] {
|
||||
// Count contacts per tag
|
||||
const tagCountMap = new Map<string, number>();
|
||||
|
||||
// This requires contacts to have a tags array - we'll estimate from the tag data
|
||||
// For now, we'll show tags with placeholder counts
|
||||
// In a real implementation, we'd need contactTags relation data
|
||||
|
||||
const result: ProgressItem[] = tags.map((tag) => ({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
color: tag.color || '#6B7280',
|
||||
total: contacts.length, // Total contacts as reference
|
||||
completed: 0, // Would need contact-tag relation to calculate
|
||||
percentage: 0,
|
||||
}));
|
||||
|
||||
return result.sort((a, b) => b.completed - a.completed);
|
||||
},
|
||||
|
||||
// Info completeness breakdown
|
||||
get infoBreakdown(): DonutSegment[] {
|
||||
const total = contacts.length;
|
||||
if (total === 0) return [];
|
||||
|
||||
const withEmail = contacts.filter((c) => c.email).length;
|
||||
const withPhone = contacts.filter((c) => c.phone || c.mobile).length;
|
||||
const withCompany = contacts.filter((c) => c.company).length;
|
||||
const withBirthday = contacts.filter((c) => c.birthday).length;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'email',
|
||||
label: 'Mit E-Mail',
|
||||
count: withEmail,
|
||||
percentage: Math.round((withEmail / total) * 100),
|
||||
color: '#3B82F6', // blue
|
||||
},
|
||||
{
|
||||
id: 'phone',
|
||||
label: 'Mit Telefon',
|
||||
count: withPhone,
|
||||
percentage: Math.round((withPhone / total) * 100),
|
||||
color: '#10B981', // green
|
||||
},
|
||||
{
|
||||
id: 'company',
|
||||
label: 'Mit Firma',
|
||||
count: withCompany,
|
||||
percentage: Math.round((withCompany / total) * 100),
|
||||
color: '#8B5CF6', // violet
|
||||
},
|
||||
{
|
||||
id: 'birthday',
|
||||
label: 'Mit Geburtstag',
|
||||
count: withBirthday,
|
||||
percentage: Math.round((withBirthday / total) * 100),
|
||||
color: '#EC4899', // pink
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// Country breakdown
|
||||
get countryBreakdown(): ProgressItem[] {
|
||||
const countryMap = new Map<string, number>();
|
||||
|
||||
contacts.forEach((c) => {
|
||||
const country = c.country || 'Unbekannt';
|
||||
countryMap.set(country, (countryMap.get(country) || 0) + 1);
|
||||
});
|
||||
|
||||
const result: ProgressItem[] = [];
|
||||
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#6B7280'];
|
||||
let colorIndex = 0;
|
||||
|
||||
countryMap.forEach((count, country) => {
|
||||
if (country !== 'Unbekannt' || count > 0) {
|
||||
result.push({
|
||||
id: country,
|
||||
name: country,
|
||||
color: colors[colorIndex % colors.length],
|
||||
total: contacts.length,
|
||||
completed: count,
|
||||
percentage: Math.round((count / contacts.length) * 100),
|
||||
});
|
||||
colorIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
return result.sort((a, b) => b.completed - a.completed).slice(0, 8);
|
||||
},
|
||||
|
||||
// Total tags count
|
||||
get totalTags() {
|
||||
return tags.length;
|
||||
},
|
||||
};
|
||||
|
|
@ -47,8 +47,8 @@
|
|||
} from '$lib/utils/contact-parser';
|
||||
import ContactsToolbar from '$lib/components/ContactsToolbar.svelte';
|
||||
import AuthGateModal from '$lib/components/AuthGateModal.svelte';
|
||||
import { sessionContactsStore } from '$lib/stores/session-contacts.svelte';
|
||||
import { GuestWelcomeModal, shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// Tags state for Quick-Create
|
||||
let availableTags = $state<{ id: string; name: string }[]>([]);
|
||||
|
|
@ -140,7 +140,6 @@
|
|||
const baseNavItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Kontakte', icon: 'users' },
|
||||
{ href: '/tags', label: 'Tags', icon: 'tag' },
|
||||
{ href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
|
||||
{ href: '/help', label: 'Hilfe', icon: 'help-circle' },
|
||||
|
|
@ -227,8 +226,17 @@
|
|||
showAuthGateModal = true;
|
||||
}
|
||||
|
||||
// Session contacts indicator
|
||||
let sessionContactCount = $derived(sessionContactsStore.count);
|
||||
// Listen for show-auth-gate events from child components
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
const handler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<{ action?: 'save' | 'sync' | 'feature' }>;
|
||||
showAuthGate(customEvent.detail?.action || 'save');
|
||||
};
|
||||
window.addEventListener('show-auth-gate', handler);
|
||||
return () => window.removeEventListener('show-auth-gate', handler);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCloseContactModal() {
|
||||
// Refresh contacts list in case something was changed
|
||||
|
|
@ -298,9 +306,6 @@
|
|||
viewModeStore.initialize();
|
||||
contactsFilterStore.initialize();
|
||||
|
||||
// Initialize session contacts for guest mode
|
||||
sessionContactsStore.initialize();
|
||||
|
||||
// Show guest welcome modal for unauthenticated users
|
||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('contacts')) {
|
||||
showGuestWelcome = true;
|
||||
|
|
@ -318,23 +323,6 @@
|
|||
} catch (e) {
|
||||
console.error('Failed to load tags:', e);
|
||||
}
|
||||
|
||||
// Check for session contacts to migrate after login
|
||||
if (sessionContactsStore.hasContacts) {
|
||||
// Migrate session contacts to cloud
|
||||
const sessionContacts = sessionContactsStore.getAllContacts();
|
||||
for (const contact of sessionContacts) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { id, userId, createdAt, updatedAt, ...contactData } = contact;
|
||||
await contactsStore.createContact(contactData);
|
||||
} catch (e) {
|
||||
console.error('Failed to migrate session contact:', e);
|
||||
}
|
||||
}
|
||||
// Clear session contacts after migration
|
||||
sessionContactsStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize sidebar mode from localStorage
|
||||
|
|
@ -366,7 +354,7 @@
|
|||
<SplitPaneContainer>
|
||||
<!-- Navigation Layout -->
|
||||
<div class="layout-container">
|
||||
<!-- Guest Mode Banner -->
|
||||
<!-- Demo Mode Banner -->
|
||||
{#if !authStore.isAuthenticated}
|
||||
<div
|
||||
class="guest-banner bg-primary/10 border-primary/20 fixed top-0 right-0 left-0 z-50 flex items-center justify-between border-b px-4 py-2"
|
||||
|
|
@ -377,21 +365,24 @@
|
|||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-foreground">
|
||||
<strong>Gast-Modus</strong>
|
||||
{#if sessionContactCount > 0}
|
||||
- {sessionContactCount}
|
||||
{sessionContactCount === 1 ? 'Kontakt' : 'Kontakte'} lokal gespeichert
|
||||
{:else}
|
||||
- Kontakte werden nur in diesem Tab gespeichert
|
||||
{/if}
|
||||
<strong>Demo-Modus</strong>
|
||||
<span class="text-muted-foreground hidden sm:inline">
|
||||
- Beispiel-Kontakte zum Ausprobieren
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => showAuthGate('sync')}
|
||||
onclick={() => showAuthGate('save')}
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-3 py-1 text-sm font-medium transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
|
|
|
|||
|
|
@ -1,281 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount, type Component } from 'svelte';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { contactsStatisticsStore } from '$lib/stores/statistics.svelte';
|
||||
import { tagsApi } from '$lib/api/contacts';
|
||||
import {
|
||||
StatsGrid,
|
||||
ActivityHeatmap,
|
||||
TrendLineChart,
|
||||
DonutChart,
|
||||
ProgressBars,
|
||||
StatisticsSkeleton,
|
||||
type StatItem,
|
||||
} from '@manacore/shared-ui';
|
||||
import { BarChart3, Users, Star, UserPlus, Cake, Mail, CircleCheck } from 'lucide-svelte';
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
// Update statistics when contacts change
|
||||
$effect(() => {
|
||||
contactsStatisticsStore.setContacts(contactsStore.contacts);
|
||||
});
|
||||
|
||||
// Build stats items for StatsGrid
|
||||
// Note: Cast icons to Component to satisfy type requirements
|
||||
let statsItems = $derived<StatItem[]>([
|
||||
{
|
||||
id: 'total',
|
||||
label: 'Gesamt',
|
||||
value: contactsStatisticsStore.totalContacts,
|
||||
icon: Users as unknown as Component,
|
||||
variant: 'primary',
|
||||
},
|
||||
{
|
||||
id: 'favorites',
|
||||
label: 'Favoriten',
|
||||
value: contactsStatisticsStore.favoriteContacts,
|
||||
icon: Star as unknown as Component,
|
||||
variant: 'accent',
|
||||
},
|
||||
{
|
||||
id: 'recentlyAdded',
|
||||
label: 'Neu (7 Tage)',
|
||||
value: contactsStatisticsStore.recentlyAdded,
|
||||
icon: UserPlus as unknown as Component,
|
||||
variant: 'success',
|
||||
},
|
||||
{
|
||||
id: 'birthdays',
|
||||
label: 'Geburtstage',
|
||||
value: contactsStatisticsStore.birthdaysThisMonth,
|
||||
icon: Cake as unknown as Component,
|
||||
variant: 'info',
|
||||
},
|
||||
{
|
||||
id: 'withEmail',
|
||||
label: 'Mit E-Mail',
|
||||
value: contactsStatisticsStore.contactsWithEmail,
|
||||
icon: Mail as unknown as Component,
|
||||
variant: 'neutral',
|
||||
},
|
||||
{
|
||||
id: 'completeness',
|
||||
label: 'Vollständigkeit',
|
||||
value: `${contactsStatisticsStore.completenessRate}%`,
|
||||
icon: CircleCheck as unknown as Component,
|
||||
variant: contactsStatisticsStore.completenessRate >= 70 ? 'success' : 'danger',
|
||||
},
|
||||
]);
|
||||
|
||||
onMount(async () => {
|
||||
// Fetch all contacts (without filters for statistics)
|
||||
await contactsStore.loadContacts({ isArchived: false });
|
||||
|
||||
// Also load archived for complete statistics
|
||||
const allContacts = [...contactsStore.contacts];
|
||||
|
||||
// Fetch tags
|
||||
try {
|
||||
const { tags } = await tagsApi.list();
|
||||
contactsStatisticsStore.setTags(tags);
|
||||
} catch (e) {
|
||||
console.error('Failed to load tags:', e);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Statistiken - Kontakte</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="statistics-page">
|
||||
<header class="page-header">
|
||||
<div class="header-icon">
|
||||
<BarChart3 size={28} />
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1>Statistiken</h1>
|
||||
<p class="header-subtitle">Deine Kontakte im Überblick</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<StatisticsSkeleton statCards={6} legendItems={4} />
|
||||
{:else}
|
||||
<!-- Quick Stats -->
|
||||
<section class="stats-section">
|
||||
<StatsGrid items={statsItems} columns={6} />
|
||||
</section>
|
||||
|
||||
<!-- Charts Grid -->
|
||||
<div class="charts-grid">
|
||||
<!-- Activity Heatmap -->
|
||||
<section class="chart-section heatmap-section">
|
||||
<ActivityHeatmap
|
||||
data={contactsStatisticsStore.activityHeatmap}
|
||||
itemName="Kontakt"
|
||||
itemNamePlural="Kontakte"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Weekly Trend + Status Donut -->
|
||||
<div class="charts-row">
|
||||
<section class="chart-section trend-section">
|
||||
<TrendLineChart
|
||||
data={contactsStatisticsStore.weeklyTrend}
|
||||
itemName="Kontakt"
|
||||
itemNamePlural="Kontakte"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="chart-section donut-section">
|
||||
<DonutChart
|
||||
data={contactsStatisticsStore.statusBreakdown}
|
||||
title="Status"
|
||||
centerLabel="Kontakte"
|
||||
centerValue={contactsStatisticsStore.totalContacts}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Info Completeness -->
|
||||
<div class="charts-row">
|
||||
<section class="chart-section info-section">
|
||||
<DonutChart
|
||||
data={contactsStatisticsStore.infoBreakdown}
|
||||
title="Informationen"
|
||||
centerLabel="Kontakte"
|
||||
centerValue={contactsStatisticsStore.totalContacts}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="chart-section country-section">
|
||||
<ProgressBars
|
||||
data={contactsStatisticsStore.countryBreakdown}
|
||||
title="Nach Land"
|
||||
emptyMessage="Keine Länder angegeben"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
<div class="additional-stats">
|
||||
<div class="stat-card-small">
|
||||
<span class="stat-label">Aktive Kontakte</span>
|
||||
<span class="stat-value">{contactsStatisticsStore.activeContacts}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-card-small">
|
||||
<span class="stat-label">Archivierte Kontakte</span>
|
||||
<span class="stat-value">{contactsStatisticsStore.archivedContacts}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-card-small">
|
||||
<span class="stat-label">Tags</span>
|
||||
<span class="stat-value">{contactsStatisticsStore.totalTags}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.statistics-page {
|
||||
padding-bottom: 6rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: hsl(var(--primary) / 0.15);
|
||||
color: hsl(var(--primary));
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0.25rem 0 0 0;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.charts-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.charts-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.additional-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card-small {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
:global(.dark) .stat-card-small {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.stat-card-small .stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.stat-card-small .stat-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
</style>
|
||||
100
apps/contacts/docs/CLEANUP_PLAN.md
Normal file
100
apps/contacts/docs/CLEANUP_PLAN.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# Contacts App - Cleanup Plan
|
||||
|
||||
Dieser Plan dokumentiert Features und Code, die überdurchschnittlich viel Komplexität erzeugen bei geringem Nutzen. Ziel ist eine schlankere, wartbarere Codebase.
|
||||
|
||||
## Status-Legende
|
||||
|
||||
- ✅ Erledigt
|
||||
- 🔄 In Bearbeitung
|
||||
- ⏳ Geplant
|
||||
- ❌ Abgelehnt
|
||||
|
||||
---
|
||||
|
||||
## Geplante Aufräumarbeiten
|
||||
|
||||
### Priorität 1: Quick Wins (Hoher ROI)
|
||||
|
||||
#### ✅ 1.1 Statistiken entfernen
|
||||
|
||||
**Status:** Erledigt
|
||||
**Geschätzte Ersparnis:** ~560 Zeilen
|
||||
**Komplexität:** MITTEL | **Nutzen:** NIEDRIG
|
||||
|
||||
**Beschreibung:**
|
||||
Analytics-Dashboard mit Activity Heatmap, Trend Charts, Donut Charts. Geringe Nutzung bei hoher Komplexität.
|
||||
|
||||
**Entfernte Dateien:**
|
||||
- `src/lib/stores/statistics.svelte.ts` (~276 Zeilen)
|
||||
- `src/routes/(app)/statistics/+page.svelte` (~282 Zeilen)
|
||||
|
||||
**Geänderte Dateien:**
|
||||
- `src/routes/(app)/+layout.svelte` - Nav-Item entfernt
|
||||
|
||||
---
|
||||
|
||||
#### ✅ 1.2 Network View entfernen
|
||||
|
||||
**Status:** Erledigt
|
||||
**Geschätzte Ersparnis:** ~1.100 Zeilen
|
||||
**Komplexität:** HOCH | **Nutzen:** NIEDRIG
|
||||
|
||||
**Beschreibung:**
|
||||
D3.js Force-Directed Graph zur Visualisierung von Kontakt-Beziehungen. Komplex (Force Simulation, Node Dragging, Filtering) aber wenig genutzt.
|
||||
|
||||
**Entfernte Dateien:**
|
||||
- `src/lib/stores/network.svelte.ts` (~540 Zeilen)
|
||||
- `src/lib/api/network.ts` (~50 Zeilen)
|
||||
- `src/lib/components/network/NetworkGraph.svelte`
|
||||
- `src/lib/components/network/NetworkControls.svelte`
|
||||
- `src/lib/components/views/ContactNetworkView.svelte`
|
||||
|
||||
**Geänderte Dateien:**
|
||||
- `src/lib/components/ContactList.svelte` - Network-View entfernt
|
||||
- `src/lib/components/ContactsToolbarContent.svelte` - Network-Controls entfernt
|
||||
|
||||
---
|
||||
|
||||
#### ✅ 1.3 Session Contacts → Demo-Modus
|
||||
|
||||
**Status:** Erledigt
|
||||
**Geschätzte Ersparnis:** ~100 Zeilen (netto)
|
||||
**Komplexität:** MITTEL | **Nutzen:** HOCH (bessere UX)
|
||||
|
||||
**Beschreibung:**
|
||||
Wie bei Calendar/Todo: Session-basiertes Kontakt-Management durch statischen Demo-Modus ersetzen. Statt frustrierender UX (Kontakte verschwinden bei Tab-Schließung) zeigt die App Beispiel-Kontakte.
|
||||
|
||||
**Entfernte Dateien:**
|
||||
- `src/lib/stores/session-contacts.svelte.ts` (~236 Zeilen)
|
||||
|
||||
**Neue Dateien:**
|
||||
- `src/lib/data/demo-contacts.ts` (~215 Zeilen) - 10 Demo-Kontakte
|
||||
|
||||
**Geänderte Dateien:**
|
||||
- `src/lib/stores/contacts.svelte.ts` - Demo-Kontakte statt Session-Logik
|
||||
- `src/routes/(app)/+layout.svelte` - Demo-Banner, Auth-Gate Events
|
||||
- `src/lib/components/ContactList.svelte` - Auth-Gate bei Favoriten
|
||||
- `src/lib/components/ContactDetailModal.svelte` - Auth-Gate bei Demo-Kontakten
|
||||
- `src/lib/components/NewContactModal.svelte` - Auth-Gate bei Erstellung
|
||||
- `src/lib/components/AuthGateModal.svelte` - Angepasste Demo-Modus Texte
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Phase | Features | LOC Ersparnis | Status |
|
||||
|-------|----------|---------------|--------|
|
||||
| 🟢 Prio 1.1 | Statistiken | ~560 | ✅ Erledigt |
|
||||
| 🟢 Prio 1.2 | Network View | ~1.100 | ✅ Erledigt |
|
||||
| 🟢 Prio 1.3 | Sessions → Demo | ~100 | ✅ Erledigt |
|
||||
| **Gesamt** | | **~1.760** | ✅ |
|
||||
|
||||
**Erreicht:** ~20% Code-Reduktion bei gleichem/besserem Nutzererlebnis
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Datum | Aktion | Commit |
|
||||
|-------|--------|--------|
|
||||
| 2026-01-28 | Statistiken, Network View, Session Contacts entfernt; Demo-Modus implementiert | - |
|
||||
Loading…
Add table
Add a link
Reference in a new issue