refactor(contacts): major component and API refactoring

Components:
- Simplify ContactDetailModal and NewContactModal
- Enhance ContactsToolbarContent with expanded functionality
- Add AlphabetNavContextMenu for alphabet navigation
- Add SocialMediaLinks component for displaying social links
- Add SocialMediaFields form component
- Add ContactNetworkView as integrated network visualization
- Improve skeleton components with shared utilities

API & Config:
- Add centralized API client module
- Refactor contacts API with better error handling
- Add social-media configuration module
- Update batch and config modules

Stores:
- Simplify filter store
- Update settings and user-settings stores
- Clean up view-mode store
- Minor auth store updates

Routes:
- Update layout with simplified navigation
- Minor updates to settings, statistics pages

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-14 21:31:36 +01:00
parent c7a9e88d13
commit fc3129aaa5
34 changed files with 1808 additions and 1171 deletions

View file

@ -31,8 +31,6 @@
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-splitscreen": "workspace:*",
"@manacore/shared-tags": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
@ -43,7 +41,9 @@
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-splitscreen": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-tags": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
@ -52,6 +52,7 @@
"d3-force": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"date-fns": "^4.1.0",
"lucide-svelte": "^0.556.0",
"svelte-i18n": "^4.0.1"
},

View file

@ -1,30 +1,4 @@
import { authStore } from '$lib/stores/auth.svelte';
import { API_BASE } from './config';
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const token = await authStore.getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Request failed');
}
return response.json();
}
import { fetchWithAuth } from './client';
export interface BatchResult {
success: number;

View file

@ -0,0 +1,71 @@
/**
* Centralized API client with authentication
*/
import { authStore } from '$lib/stores/auth.svelte';
import { API_BASE } from './config';
/**
* Make an authenticated API request
* @param url API endpoint (will be prefixed with API_BASE)
* @param options Fetch options
* @returns Parsed JSON response
*/
export async function fetchWithAuth<T = unknown>(
url: string,
options: RequestInit = {}
): Promise<T> {
const token = await authStore.getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Request failed');
}
return response.json();
}
/**
* Make an authenticated API request without JSON content type
* Used for file uploads (FormData)
*/
export async function fetchWithAuthFormData<T = unknown>(
url: string,
options: RequestInit = {}
): Promise<T> {
const token = await authStore.getAccessToken();
const headers: HeadersInit = {
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Request failed');
}
return response.json();
}

View file

@ -1,7 +1,13 @@
import { PUBLIC_BACKEND_URL } from '$env/static/public';
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
/**
* API Configuration
* Uses environment variable PUBLIC_BACKEND_URL with fallback for development
* Uses environment variables with fallbacks for development
*/
export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`;
/**
* Mana Core Auth URL
* Central authentication service URL
*/
export const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';

View file

@ -1,33 +1,9 @@
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
import { API_BASE } from './config';
import { MANA_AUTH_URL } from './config';
import { fetchWithAuth, fetchWithAuthFormData } from './client';
import { createTagsClient, type Tag } from '@manacore/shared-tags';
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const token = await authStore.getAccessToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${url}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || 'Request failed');
}
return response.json();
}
export interface Contact {
id: string;
userId: string;
@ -63,6 +39,8 @@ export interface Contact {
signal?: string | null;
discord?: string | null;
bluesky?: string | null;
// Tags (populated by API)
tags?: Array<{ id: string; name: string; color: string | null }>;
isFavorite: boolean;
isArchived: boolean;
organizationId?: string | null;
@ -104,9 +82,19 @@ export interface ContactFilters {
offset?: number;
}
// API Response types
interface ContactResponse {
contact: Contact;
}
interface ContactListResponse {
contacts: Contact[];
total: number;
}
// Contacts API
export const contactsApi = {
async list(filters: ContactFilters = {}) {
async list(filters: ContactFilters = {}): Promise<ContactListResponse> {
const params = new URLSearchParams();
if (filters.search) params.set('search', filters.search);
if (filters.isFavorite !== undefined) params.set('isFavorite', String(filters.isFavorite));
@ -116,16 +104,16 @@ export const contactsApi = {
if (filters.offset) params.set('offset', String(filters.offset));
const query = params.toString();
return fetchWithAuth(`/contacts${query ? `?${query}` : ''}`);
return fetchWithAuth<ContactListResponse>(`/contacts${query ? `?${query}` : ''}`);
},
async get(id: string): Promise<Contact> {
const response = await fetchWithAuth(`/contacts/${id}`);
const response = await fetchWithAuth<ContactResponse>(`/contacts/${id}`);
return response.contact;
},
async create(data: Partial<Contact>): Promise<Contact> {
const response = await fetchWithAuth('/contacts', {
const response = await fetchWithAuth<ContactResponse>('/contacts', {
method: 'POST',
body: JSON.stringify(data),
});
@ -133,7 +121,7 @@ export const contactsApi = {
},
async update(id: string, data: Partial<Contact>): Promise<Contact> {
const response = await fetchWithAuth(`/contacts/${id}`, {
const response = await fetchWithAuth<ContactResponse>(`/contacts/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
@ -147,14 +135,14 @@ export const contactsApi = {
},
async toggleFavorite(id: string): Promise<Contact> {
const response = await fetchWithAuth(`/contacts/${id}/favorite`, {
const response = await fetchWithAuth<ContactResponse>(`/contacts/${id}/favorite`, {
method: 'POST',
});
return response.contact;
},
async toggleArchive(id: string): Promise<Contact> {
const response = await fetchWithAuth(`/contacts/${id}/archive`, {
const response = await fetchWithAuth<ContactResponse>(`/contacts/${id}/archive`, {
method: 'POST',
});
return response.contact;
@ -164,16 +152,6 @@ export const contactsApi = {
// Tags API - Uses central Tags API from mana-core-auth
// Contact-tag associations still use the Contacts backend
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return 'http://localhost:3001';
}
// Lazy-initialized tags client
let _tagsClient: ReturnType<typeof createTagsClient> | null = null;
@ -181,7 +159,7 @@ function getTagsClient() {
if (!browser) return null;
if (!_tagsClient) {
_tagsClient = createTagsClient({
authUrl: getAuthUrl(),
authUrl: MANA_AUTH_URL,
getToken: async () => {
const token = await authStore.getAccessToken();
return token || '';
@ -226,19 +204,19 @@ export const tagsApi = {
// Contact-tag associations still use Contacts backend
async addToContact(tagId: string, contactId: string): Promise<{ success: boolean }> {
return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, {
return fetchWithAuth<{ success: boolean }>(`/tags/${tagId}/contacts/${contactId}`, {
method: 'POST',
});
},
async removeFromContact(tagId: string, contactId: string): Promise<{ success: boolean }> {
return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, {
return fetchWithAuth<{ success: boolean }>(`/tags/${tagId}/contacts/${contactId}`, {
method: 'DELETE',
});
},
async getForContact(contactId: string): Promise<{ tagIds: string[] }> {
return fetchWithAuth(`/tags/contact/${contactId}`);
return fetchWithAuth<{ tagIds: string[] }>(`/tags/contact/${contactId}`);
},
// Create default tags via central Tags API
@ -250,44 +228,68 @@ export const tagsApi = {
},
};
// Notes API Response types
interface NotesListResponse {
notes: ContactNote[];
}
interface NoteResponse {
note: ContactNote;
}
// Notes API
export const notesApi = {
async list(contactId: string) {
return fetchWithAuth(`/contacts/${contactId}/notes`);
async list(contactId: string): Promise<NotesListResponse> {
return fetchWithAuth<NotesListResponse>(`/contacts/${contactId}/notes`);
},
async create(contactId: string, data: { content: string; isPinned?: boolean }) {
return fetchWithAuth(`/contacts/${contactId}/notes`, {
async create(
contactId: string,
data: { content: string; isPinned?: boolean }
): Promise<NoteResponse> {
return fetchWithAuth<NoteResponse>(`/contacts/${contactId}/notes`, {
method: 'POST',
body: JSON.stringify(data),
});
},
async update(noteId: string, data: { content?: string; isPinned?: boolean }) {
return fetchWithAuth(`/notes/${noteId}`, {
async update(
noteId: string,
data: { content?: string; isPinned?: boolean }
): Promise<NoteResponse> {
return fetchWithAuth<NoteResponse>(`/notes/${noteId}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
},
async delete(noteId: string) {
return fetchWithAuth(`/notes/${noteId}`, {
async delete(noteId: string): Promise<void> {
await fetchWithAuth(`/notes/${noteId}`, {
method: 'DELETE',
});
},
async togglePin(noteId: string) {
return fetchWithAuth(`/notes/${noteId}/pin`, {
async togglePin(noteId: string): Promise<NoteResponse> {
return fetchWithAuth<NoteResponse>(`/notes/${noteId}/pin`, {
method: 'POST',
});
},
};
// Activities API Response types
interface ActivitiesListResponse {
activities: ContactActivity[];
}
interface ActivityResponse {
activity: ContactActivity;
}
// Activities API
export const activitiesApi = {
async list(contactId: string, limit?: number) {
async list(contactId: string, limit?: number): Promise<ActivitiesListResponse> {
const params = limit ? `?limit=${limit}` : '';
return fetchWithAuth(`/contacts/${contactId}/activities${params}`);
return fetchWithAuth<ActivitiesListResponse>(`/contacts/${contactId}/activities${params}`);
},
async create(
@ -297,8 +299,8 @@ export const activitiesApi = {
description?: string;
metadata?: Record<string, unknown>;
}
) {
return fetchWithAuth(`/contacts/${contactId}/activities`, {
): Promise<ActivityResponse> {
return fetchWithAuth<ActivityResponse>(`/contacts/${contactId}/activities`, {
method: 'POST',
body: JSON.stringify(data),
});
@ -308,25 +310,13 @@ export const activitiesApi = {
// Photo API
export const photoApi = {
async upload(contactId: string, file: File): Promise<{ photoUrl: string }> {
const token = await authStore.getAccessToken();
const formData = new FormData();
formData.append('photo', file);
const response = await fetch(`${API_BASE}/contacts/${contactId}/photo`, {
return fetchWithAuthFormData<{ photoUrl: string }>(`/contacts/${contactId}/photo`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Upload failed' }));
throw new Error(error.message || 'Upload failed');
}
return response.json();
},
async delete(contactId: string): Promise<void> {

View file

@ -0,0 +1,86 @@
<script lang="ts">
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { EyeSlash, ArrowsDownUp, Hash, ArrowsIn, ArrowsOut } from '@manacore/shared-icons';
import { contactsSettings } from '$lib/stores/settings.svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
// Context menu state
let visible = $state(false);
let x = $state(0);
let y = $state(0);
// Build menu items based on current settings
let menuItems = $derived.by((): ContextMenuItem[] => {
return [
{
id: 'hide-inactive',
label: 'Inaktive Buchstaben ausblenden',
icon: EyeSlash,
toggle: true,
checked: contactsSettings.alphabetNavHideInactive,
action: () =>
contactsSettings.set(
'alphabetNavHideInactive',
!contactsSettings.alphabetNavHideInactive
),
},
{
id: 'compact',
label: 'Kompakte Ansicht',
icon: ArrowsIn,
toggle: true,
checked: contactsSettings.alphabetNavCompact,
action: () =>
contactsSettings.set('alphabetNavCompact', !contactsSettings.alphabetNavCompact),
},
{
id: 'reverse-order',
label: 'Umgekehrte Reihenfolge (Z-A)',
icon: ArrowsDownUp,
toggle: true,
checked: contactsSettings.alphabetNavReverseOrder,
action: () =>
contactsSettings.set(
'alphabetNavReverseOrder',
!contactsSettings.alphabetNavReverseOrder
),
},
{
id: 'show-hash',
label: '# Symbol anzeigen',
icon: Hash,
toggle: true,
checked: contactsSettings.alphabetNavShowHash,
action: () =>
contactsSettings.set('alphabetNavShowHash', !contactsSettings.alphabetNavShowHash),
},
{
id: 'divider-1',
label: '',
type: 'divider',
},
{
id: 'minimize',
label: contactsFilterStore.isAlphabetNavCollapsed ? 'Erweitern' : 'Minimieren',
icon: contactsFilterStore.isAlphabetNavCollapsed ? ArrowsOut : ArrowsIn,
action: () => contactsFilterStore.toggleAlphabetNav(),
},
];
});
function handleClose() {
visible = false;
}
export function show(clientX: number, clientY: number) {
x = clientX;
y = clientY;
visible = true;
}
export function hide() {
visible = false;
}
</script>
<ContextMenu {visible} {x} {y} items={menuItems} onClose={handleClose} />

View file

@ -5,6 +5,8 @@
import ContactNotes from './ContactNotes.svelte';
import ContactTasks from './ContactTasks.svelte';
import { ContactDetailSkeleton } from '$lib/components/skeletons';
import SocialMediaFields from './forms/SocialMediaFields.svelte';
import SocialMediaLinks from './SocialMediaLinks.svelte';
interface Props {
contactId: string;
@ -50,7 +52,6 @@
let signal = $state('');
let discord = $state('');
let bluesky = $state('');
let socialSectionOpen = $state(false);
const initials = $derived(() => {
if (!contact) return '?';
@ -100,22 +101,6 @@
signal = contact.signal || '';
discord = contact.discord || '';
bluesky = contact.bluesky || '';
// Auto-open social section if any social field has data
socialSectionOpen = !!(
contact.linkedin ||
contact.twitter ||
contact.facebook ||
contact.instagram ||
contact.xing ||
contact.github ||
contact.youtube ||
contact.tiktok ||
contact.telegram ||
contact.whatsapp ||
contact.signal ||
contact.discord ||
contact.bluesky
);
}
function getDisplayName() {
@ -538,213 +523,22 @@
<textarea bind:value={notes} rows="4" class="input textarea"></textarea>
</section>
<!-- Social Media Section (Collapsible) -->
<section class="form-section">
<button
type="button"
class="section-header section-header-toggle"
onclick={() => (socialSectionOpen = !socialSectionOpen)}
>
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"
/>
</svg>
</div>
<h2 class="section-title">Social Media</h2>
<svg
class="chevron-icon"
class:chevron-open={socialSectionOpen}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if socialSectionOpen}
<div class="social-grid">
<div class="form-field">
<label for="linkedin" class="label social-label">
<span class="social-icon-label">in</span>
LinkedIn
</label>
<input
id="linkedin"
type="url"
bind:value={linkedin}
class="input"
placeholder="https://linkedin.com/in/..."
/>
</div>
<div class="form-field">
<label for="twitter" class="label social-label">
<span class="social-icon-label">X</span>
Twitter / X
</label>
<input
id="twitter"
type="text"
bind:value={twitter}
class="input"
placeholder="@username"
/>
</div>
<div class="form-field">
<label for="facebook" class="label social-label">
<span class="social-icon-label">f</span>
Facebook
</label>
<input
id="facebook"
type="url"
bind:value={facebook}
class="input"
placeholder="https://facebook.com/..."
/>
</div>
<div class="form-field">
<label for="instagram" class="label social-label">
<span class="social-icon-label">ig</span>
Instagram
</label>
<input
id="instagram"
type="text"
bind:value={instagram}
class="input"
placeholder="@username"
/>
</div>
<div class="form-field">
<label for="xing" class="label social-label">
<span class="social-icon-label">xi</span>
Xing
</label>
<input
id="xing"
type="url"
bind:value={xing}
class="input"
placeholder="https://xing.com/profile/..."
/>
</div>
<div class="form-field">
<label for="github" class="label social-label">
<span class="social-icon-label">gh</span>
GitHub
</label>
<input
id="github"
type="text"
bind:value={github}
class="input"
placeholder="username"
/>
</div>
<div class="form-field">
<label for="youtube" class="label social-label">
<span class="social-icon-label">yt</span>
YouTube
</label>
<input
id="youtube"
type="url"
bind:value={youtube}
class="input"
placeholder="https://youtube.com/@..."
/>
</div>
<div class="form-field">
<label for="tiktok" class="label social-label">
<span class="social-icon-label">tt</span>
TikTok
</label>
<input
id="tiktok"
type="text"
bind:value={tiktok}
class="input"
placeholder="@username"
/>
</div>
<div class="form-field">
<label for="telegram" class="label social-label">
<span class="social-icon-label">tg</span>
Telegram
</label>
<input
id="telegram"
type="text"
bind:value={telegram}
class="input"
placeholder="@username"
/>
</div>
<div class="form-field">
<label for="whatsapp" class="label social-label">
<span class="social-icon-label">wa</span>
WhatsApp
</label>
<input
id="whatsapp"
type="tel"
bind:value={whatsapp}
class="input"
placeholder="+49..."
/>
</div>
<div class="form-field">
<label for="signal" class="label social-label">
<span class="social-icon-label">sg</span>
Signal
</label>
<input
id="signal"
type="tel"
bind:value={signal}
class="input"
placeholder="+49..."
/>
</div>
<div class="form-field">
<label for="discord" class="label social-label">
<span class="social-icon-label">dc</span>
Discord
</label>
<input
id="discord"
type="text"
bind:value={discord}
class="input"
placeholder="username#1234"
/>
</div>
<div class="form-field">
<label for="bluesky" class="label social-label">
<span class="social-icon-label">bs</span>
Bluesky
</label>
<input
id="bluesky"
type="text"
bind:value={bluesky}
class="input"
placeholder="@handle.bsky.social"
/>
</div>
</div>
{/if}
</section>
<!-- Social Media Section -->
<SocialMediaFields
bind:linkedin
bind:twitter
bind:facebook
bind:instagram
bind:xing
bind:github
bind:youtube
bind:tiktok
bind:telegram
bind:whatsapp
bind:signal
bind:discord
bind:bluesky
/>
<!-- Action Buttons -->
<div class="actions">
@ -1108,176 +902,7 @@
</section>
{/if}
{#if contact.linkedin || contact.twitter || contact.facebook || contact.instagram || contact.xing || contact.github || contact.youtube || contact.tiktok || contact.telegram || contact.whatsapp || contact.signal || contact.discord || contact.bluesky}
<section class="detail-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"
/>
</svg>
</div>
<h3 class="section-title">Social Media</h3>
</div>
<div class="social-links-grid">
{#if contact.linkedin}
<a
href={contact.linkedin.startsWith('http')
? contact.linkedin
: `https://linkedin.com/in/${contact.linkedin}`}
target="_blank"
rel="noopener noreferrer"
class="social-link"
>
<span class="social-badge">in</span>
<span class="social-link-text">LinkedIn</span>
</a>
{/if}
{#if contact.twitter}
<a
href={contact.twitter.startsWith('http')
? contact.twitter
: `https://x.com/${contact.twitter.replace('@', '')}`}
target="_blank"
rel="noopener noreferrer"
class="social-link"
>
<span class="social-badge">X</span>
<span class="social-link-text">Twitter / X</span>
</a>
{/if}
{#if contact.facebook}
<a
href={contact.facebook.startsWith('http')
? contact.facebook
: `https://facebook.com/${contact.facebook}`}
target="_blank"
rel="noopener noreferrer"
class="social-link"
>
<span class="social-badge">f</span>
<span class="social-link-text">Facebook</span>
</a>
{/if}
{#if contact.instagram}
<a
href={contact.instagram.startsWith('http')
? contact.instagram
: `https://instagram.com/${contact.instagram.replace('@', '')}`}
target="_blank"
rel="noopener noreferrer"
class="social-link"
>
<span class="social-badge">ig</span>
<span class="social-link-text">Instagram</span>
</a>
{/if}
{#if contact.xing}
<a
href={contact.xing.startsWith('http')
? contact.xing
: `https://xing.com/profile/${contact.xing}`}
target="_blank"
rel="noopener noreferrer"
class="social-link"
>
<span class="social-badge">xi</span>
<span class="social-link-text">Xing</span>
</a>
{/if}
{#if contact.github}
<a
href={contact.github.startsWith('http')
? contact.github
: `https://github.com/${contact.github}`}
target="_blank"
rel="noopener noreferrer"
class="social-link"
>
<span class="social-badge">gh</span>
<span class="social-link-text">GitHub</span>
</a>
{/if}
{#if contact.youtube}
<a
href={contact.youtube.startsWith('http')
? contact.youtube
: `https://youtube.com/@${contact.youtube}`}
target="_blank"
rel="noopener noreferrer"
class="social-link"
>
<span class="social-badge">yt</span>
<span class="social-link-text">YouTube</span>
</a>
{/if}
{#if contact.tiktok}
<a
href={contact.tiktok.startsWith('http')
? contact.tiktok
: `https://tiktok.com/@${contact.tiktok.replace('@', '')}`}
target="_blank"
rel="noopener noreferrer"
class="social-link"
>
<span class="social-badge">tt</span>
<span class="social-link-text">TikTok</span>
</a>
{/if}
{#if contact.telegram}
<a
href={`https://t.me/${contact.telegram.replace('@', '')}`}
target="_blank"
rel="noopener noreferrer"
class="social-link"
>
<span class="social-badge">tg</span>
<span class="social-link-text">Telegram</span>
</a>
{/if}
{#if contact.whatsapp}
<a
href={`https://wa.me/${contact.whatsapp.replace(/[^0-9]/g, '')}`}
target="_blank"
rel="noopener noreferrer"
class="social-link"
>
<span class="social-badge">wa</span>
<span class="social-link-text">WhatsApp</span>
</a>
{/if}
{#if contact.signal}
<span class="social-link social-link-static">
<span class="social-badge">sg</span>
<span class="social-link-text">{contact.signal}</span>
</span>
{/if}
{#if contact.discord}
<span class="social-link social-link-static">
<span class="social-badge">dc</span>
<span class="social-link-text">{contact.discord}</span>
</span>
{/if}
{#if contact.bluesky}
<a
href={contact.bluesky.startsWith('http')
? contact.bluesky
: `https://bsky.app/profile/${contact.bluesky.replace('@', '')}`}
target="_blank"
rel="noopener noreferrer"
class="social-link"
>
<span class="social-badge">bs</span>
<span class="social-link-text">Bluesky</span>
</a>
{/if}
</div>
</section>
{/if}
<SocialMediaLinks {contact} />
<!-- Contact Notes (separate from contact.notes field) -->
<ContactNotes {contactId} />
@ -1981,122 +1606,5 @@
.quick-actions {
gap: 1rem;
}
.social-grid {
grid-template-columns: 1fr;
}
}
/* Social Media Section */
.section-header-toggle {
width: 100%;
background: none;
border: none;
cursor: pointer;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
margin-bottom: 0;
}
.section-header-toggle:hover {
background: hsl(var(--color-surface-hover) / 0.3);
margin: 0 -1rem;
padding: 0 1rem 0.625rem;
width: calc(100% + 2rem);
border-radius: 0.5rem 0.5rem 0 0;
}
.chevron-icon {
width: 1rem;
height: 1rem;
margin-left: auto;
color: hsl(var(--color-muted-foreground));
transition: transform 0.2s ease;
}
.chevron-open {
transform: rotate(180deg);
}
.social-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
padding-top: 0.75rem;
}
.social-label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.social-icon-label {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
font-size: 0.625rem;
font-weight: 700;
text-transform: lowercase;
}
/* Social Links in View Mode */
.social-links-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.social-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
background: hsl(var(--color-muted) / 0.5);
border-radius: 0.5rem;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
}
.social-link:hover:not(.social-link-static) {
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
.social-link-static {
cursor: default;
}
.social-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
font-size: 0.625rem;
font-weight: 700;
flex-shrink: 0;
}
.social-link-text {
font-size: 0.8125rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 480px) {
.social-links-grid {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -5,12 +5,17 @@
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import { goto } from '$app/navigation';
import ContactListView from '$lib/components/views/ContactListView.svelte';
import ContactGridView from '$lib/components/views/ContactGridView.svelte';
import ContactAlphabetView from '$lib/components/views/ContactAlphabetView.svelte';
import { ContactListSkeleton, ContactGridSkeleton } from '$lib/components/skeletons';
import ContactNetworkView from '$lib/components/views/ContactNetworkView.svelte';
import {
ContactListSkeleton,
ContactGridSkeleton,
NetworkGraphSkeleton,
} 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';
// Infinite scroll
let intersectionObserver: IntersectionObserver | null = null;
@ -288,7 +293,7 @@
<div class="space-y-6">
<!-- Header -->
<h1 class="text-2xl font-bold text-foreground">{$_('contacts.title')}</h1>
<h1 class="text-2xl font-bold text-foreground text-center">{$_('contacts.title')}</h1>
<!-- Batch Actions Bar (shown when in selection mode) -->
{#if selectionMode}
@ -369,7 +374,9 @@
<!-- Loading state with skeleton -->
{#if contactsStore.loading}
{#if viewModeStore.mode === 'grid'}
{#if viewModeStore.mode === 'network'}
<NetworkGraphSkeleton />
{:else if viewModeStore.mode === 'grid'}
<ContactGridSkeleton count={8} />
{:else}
<ContactListSkeleton count={10} />
@ -380,13 +387,15 @@
<div class="text-6xl mb-4">👤</div>
<h2 class="text-xl font-semibold text-foreground mb-2">{$_('contacts.noContacts')}</h2>
<p class="text-muted-foreground mb-4">{$_('contacts.addFirst')}</p>
<a href="/contacts/new" class="btn btn-primary">
<button type="button" onclick={() => newContactModalStore.open()} class="btn btn-primary">
{$_('contacts.new')}
</a>
</button>
</div>
{:else}
<!-- Contacts View -->
{#if viewModeStore.mode === 'grid'}
{#if viewModeStore.mode === 'network'}
<ContactNetworkView />
{:else if viewModeStore.mode === 'grid'}
<ContactGridView
contacts={sortedContacts}
onContactClick={handleContactClick}
@ -395,7 +404,7 @@
{selectedIds}
onToggleSelection={toggleSelection}
/>
{:else if viewModeStore.mode === 'alphabet'}
{:else}
<ContactAlphabetView
contacts={sortedContacts}
onContactClick={handleContactClick}
@ -405,19 +414,10 @@
onToggleSelection={toggleSelection}
sortField={contactsFilterStore.sortField}
/>
{:else}
<ContactListView
contacts={sortedContacts}
onContactClick={handleContactClick}
onToggleFavorite={handleToggleFavorite}
{selectionMode}
{selectedIds}
onToggleSelection={toggleSelection}
/>
{/if}
<!-- Infinite scroll trigger & loading more indicator -->
{#if contactsStore.hasMore}
<!-- Infinite scroll trigger & loading more indicator (not for network view) -->
{#if viewModeStore.mode !== 'network' && contactsStore.hasMore}
<div bind:this={loadMoreTrigger} class="load-more-trigger">
{#if contactsStore.loadingMore}
<div class="loading-more">
@ -428,11 +428,13 @@
</div>
{/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>
<!-- 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}
{/if}
</div>

View file

@ -1,10 +1,13 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { PillViewSwitcher } from '@manacore/shared-ui';
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 { contactsFilterStore } from '$lib/stores/filter.svelte';
import FilterBar from '$lib/components/FilterBar.svelte';
import type { Contact } from '$lib/api/contacts';
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';
interface Props {
contacts: Contact[];
@ -12,6 +15,81 @@
let { contacts }: Props = $props();
// Tags for filter
let tags = $state<ContactTag[]>([]);
// Tag options for FilterDropdown
let tagOptions = $derived<FilterDropdownOption[]>(
tags.map((tag) => ({ value: tag.id, label: tag.name }))
);
// Contact filter options
let contactFilterOptions = $derived<FilterDropdownOption[]>([
{ value: 'all', label: $_('filters.contact.all') },
{ value: 'favorites', label: $_('filters.contact.favorites') },
{ value: 'hasPhone', label: $_('filters.contact.hasPhone') },
{ value: 'hasEmail', label: $_('filters.contact.hasEmail') },
{ value: 'incomplete', label: $_('filters.contact.incomplete') },
]);
// Birthday filter options
let birthdayFilterOptions = $derived<FilterDropdownOption[]>([
{ value: 'all', label: $_('filters.birthday.all') },
{ value: 'today', label: $_('filters.birthday.today') },
{ value: 'thisWeek', label: $_('filters.birthday.thisWeek') },
{ value: 'thisMonth', label: $_('filters.birthday.thisMonth') },
]);
// Extract unique companies from contacts
let companies = $derived.by(() => {
const companySet = new Set<string>();
for (const contact of contacts) {
if (contact.company) {
companySet.add(contact.company);
}
}
return Array.from(companySet).sort((a, b) => a.localeCompare(b, 'de'));
});
// Company options for FilterDropdown
let companyOptions = $derived<FilterDropdownOption[]>(
companies.map((company) => ({ value: company, label: company }))
);
// Count active filters
let activeFilterCount = $derived.by(() => {
let count = 0;
if (contactsFilterStore.selectedTagId) count++;
if (contactsFilterStore.contactFilter !== 'all') count++;
if (contactsFilterStore.birthdayFilter !== 'all') count++;
if (contactsFilterStore.selectedCompany) count++;
return count;
});
async function loadTags() {
try {
const response = await tagsApi.list();
tags = response.tags || [];
} catch (e) {
console.error('Failed to load tags:', e);
}
}
function clearAllFilters() {
contactsFilterStore.setSelectedTagId(null);
contactsFilterStore.setContactFilter('all');
contactsFilterStore.setBirthdayFilter('all');
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') },
@ -21,87 +99,174 @@
function handleSortChange(value: string) {
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">
<!-- Filter Dropdown -->
<FilterBar
{contacts}
selectedTagId={contactsFilterStore.selectedTagId}
onTagChange={(id) => contactsFilterStore.setSelectedTagId(id)}
contactFilter={contactsFilterStore.contactFilter}
onContactFilterChange={(f) => contactsFilterStore.setContactFilter(f)}
birthdayFilter={contactsFilterStore.birthdayFilter}
onBirthdayFilterChange={(f) => contactsFilterStore.setBirthdayFilter(f)}
selectedCompany={contactsFilterStore.selectedCompany}
onCompanyChange={(c) => contactsFilterStore.setSelectedCompany(c)}
embedded={true}
/>
{#if isNetworkMode}
<!-- Network Mode Controls -->
<div class="toolbar-divider"></div>
<!-- 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')}
/>
</div>
<!-- Sort Toggle -->
<PillViewSwitcher
options={sortOptions}
value={contactsFilterStore.sortField}
onChange={handleSortChange}
primaryColor="#3b82f6"
embedded={true}
/>
<div class="toolbar-divider"></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>
<!-- View Mode -->
<div class="view-mode-buttons">
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'list'}
onclick={() => viewModeStore.setMode('list')}
title={$_('views.list')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
<!-- 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) -->
<!-- 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"
/>
<!-- 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"
/>
</svg>
</button>
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'grid'}
onclick={() => viewModeStore.setMode('grid')}
title={$_('views.grid')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"
/>
</svg>
</button>
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'alphabet'}
onclick={() => viewModeStore.setMode('alphabet')}
title={$_('views.alphabet')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"
/>
</svg>
</button>
</div>
{/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}
</div>
<style>
@ -109,6 +274,7 @@
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.toolbar-divider {
@ -118,13 +284,58 @@
margin: 0 0.25rem;
}
.view-mode-buttons {
/* 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;
}
.view-btn {
.control-btn {
display: flex;
align-items: center;
justify-content: center;
@ -133,29 +344,69 @@
border: none;
border-radius: 9999px;
cursor: pointer;
color: #374151;
color: hsl(var(--color-muted-foreground));
transition: all 0.15s ease;
}
:global(.dark) .view-btn {
color: #f3f4f6;
.control-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.view-btn:hover {
background: rgba(0, 0, 0, 0.05);
.control-btn:active {
transform: scale(0.95);
}
:global(.dark) .view-btn:hover {
background: rgba(255, 255, 255, 0.1);
.clear-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
background: hsl(var(--destructive) / 0.1);
border: none;
border-radius: 0.5rem;
color: hsl(var(--destructive));
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.view-btn.active {
background: color-mix(in srgb, #3b82f6 15%, transparent 85%);
color: #3b82f6;
.clear-btn:hover {
background: hsl(var(--destructive) / 0.15);
}
.view-btn :global(svg) {
width: 1rem;
height: 1rem;
.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;
align-items: center;
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>

View file

@ -3,6 +3,7 @@
import { contactsApi, photoApi } from '$lib/api/contacts';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
import SocialMediaFields from './forms/SocialMediaFields.svelte';
interface Props {
onClose: () => void;
@ -48,7 +49,6 @@
let signal = $state('');
let discord = $state('');
let bluesky = $state('');
let socialSectionOpen = $state(false);
const initials = $derived(() => {
const f = firstName?.[0] || '';
@ -533,213 +533,22 @@
></textarea>
</section>
<!-- Social Media Section (Collapsible) -->
<section class="form-section">
<button
type="button"
class="section-header section-header-toggle"
onclick={() => (socialSectionOpen = !socialSectionOpen)}
>
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"
/>
</svg>
</div>
<h2 class="section-title">Social Media</h2>
<svg
class="chevron-icon"
class:chevron-open={socialSectionOpen}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{#if socialSectionOpen}
<div class="social-grid">
<div class="form-field">
<label for="linkedin" class="label social-label">
<span class="social-icon-label">in</span>
LinkedIn
</label>
<input
id="linkedin"
type="url"
bind:value={linkedin}
class="input"
placeholder="https://linkedin.com/in/..."
/>
</div>
<div class="form-field">
<label for="twitter" class="label social-label">
<span class="social-icon-label">X</span>
Twitter / X
</label>
<input
id="twitter"
type="text"
bind:value={twitter}
class="input"
placeholder="@username"
/>
</div>
<div class="form-field">
<label for="facebook" class="label social-label">
<span class="social-icon-label">f</span>
Facebook
</label>
<input
id="facebook"
type="url"
bind:value={facebook}
class="input"
placeholder="https://facebook.com/..."
/>
</div>
<div class="form-field">
<label for="instagram" class="label social-label">
<span class="social-icon-label">ig</span>
Instagram
</label>
<input
id="instagram"
type="text"
bind:value={instagram}
class="input"
placeholder="@username"
/>
</div>
<div class="form-field">
<label for="xing" class="label social-label">
<span class="social-icon-label">xi</span>
Xing
</label>
<input
id="xing"
type="url"
bind:value={xing}
class="input"
placeholder="https://xing.com/profile/..."
/>
</div>
<div class="form-field">
<label for="github" class="label social-label">
<span class="social-icon-label">gh</span>
GitHub
</label>
<input
id="github"
type="text"
bind:value={github}
class="input"
placeholder="username"
/>
</div>
<div class="form-field">
<label for="youtube" class="label social-label">
<span class="social-icon-label">yt</span>
YouTube
</label>
<input
id="youtube"
type="url"
bind:value={youtube}
class="input"
placeholder="https://youtube.com/@..."
/>
</div>
<div class="form-field">
<label for="tiktok" class="label social-label">
<span class="social-icon-label">tt</span>
TikTok
</label>
<input
id="tiktok"
type="text"
bind:value={tiktok}
class="input"
placeholder="@username"
/>
</div>
<div class="form-field">
<label for="telegram" class="label social-label">
<span class="social-icon-label">tg</span>
Telegram
</label>
<input
id="telegram"
type="text"
bind:value={telegram}
class="input"
placeholder="@username"
/>
</div>
<div class="form-field">
<label for="whatsapp" class="label social-label">
<span class="social-icon-label">wa</span>
WhatsApp
</label>
<input
id="whatsapp"
type="tel"
bind:value={whatsapp}
class="input"
placeholder="+49..."
/>
</div>
<div class="form-field">
<label for="signal" class="label social-label">
<span class="social-icon-label">sg</span>
Signal
</label>
<input
id="signal"
type="tel"
bind:value={signal}
class="input"
placeholder="+49..."
/>
</div>
<div class="form-field">
<label for="discord" class="label social-label">
<span class="social-icon-label">dc</span>
Discord
</label>
<input
id="discord"
type="text"
bind:value={discord}
class="input"
placeholder="username#1234"
/>
</div>
<div class="form-field">
<label for="bluesky" class="label social-label">
<span class="social-icon-label">bs</span>
Bluesky
</label>
<input
id="bluesky"
type="text"
bind:value={bluesky}
class="input"
placeholder="@handle.bsky.social"
/>
</div>
</div>
{/if}
</section>
<!-- Social Media Section -->
<SocialMediaFields
bind:linkedin
bind:twitter
bind:facebook
bind:instagram
bind:xing
bind:github
bind:youtube
bind:tiktok
bind:telegram
bind:whatsapp
bind:signal
bind:discord
bind:bluesky
/>
<!-- Action Buttons -->
<div class="actions">
@ -1195,66 +1004,5 @@
.actions {
flex-direction: column-reverse;
}
.social-grid {
grid-template-columns: 1fr;
}
}
/* Social Media Section */
.section-header-toggle {
width: 100%;
background: none;
border: none;
cursor: pointer;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
margin-bottom: 0;
}
.section-header-toggle:hover {
background: hsl(var(--color-surface-hover) / 0.3);
margin: 0 -1rem;
padding: 0 1rem 0.625rem;
width: calc(100% + 2rem);
border-radius: 0.5rem 0.5rem 0 0;
}
.chevron-icon {
width: 1rem;
height: 1rem;
margin-left: auto;
color: hsl(var(--color-muted-foreground));
transition: transform 0.2s ease;
}
.chevron-open {
transform: rotate(180deg);
}
.social-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
padding-top: 0.75rem;
}
.social-label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.social-icon-label {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
font-size: 0.625rem;
font-weight: 700;
text-transform: lowercase;
}
</style>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { contactsApi, type Contact } from '$lib/api/contacts';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
interface Props {
open: boolean;
@ -196,7 +197,14 @@
{:else}
<!-- Quick Actions when no search -->
<div class="quick-actions-list">
<a href="/contacts/new" class="quick-action" onclick={onClose}>
<button
type="button"
class="quick-action"
onclick={() => {
onClose();
newContactModalStore.open();
}}
>
<svg class="quick-action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
@ -207,18 +215,7 @@
</svg>
<span>Neuen Kontakt erstellen</span>
<kbd>N</kbd>
</a>
<a href="/favorites" class="quick-action" onclick={onClose}>
<svg class="quick-action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<span>Favoriten anzeigen</span>
</a>
</button>
<a href="/tags" class="quick-action" onclick={onClose}>
<svg class="quick-action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path

View file

@ -0,0 +1,151 @@
<script lang="ts">
/**
* SocialMediaLinks - Display social media links for a contact (view mode)
* Uses centralized SOCIAL_PLATFORMS config
*/
import { getSocialMediaEntries, hasSocialMedia } from '$lib/config/social-media';
import type { Contact } from '$lib/api/contacts';
interface Props {
contact: Contact;
}
let { contact }: Props = $props();
const entries = $derived(getSocialMediaEntries(contact as unknown as Record<string, unknown>));
const hasAny = $derived(hasSocialMedia(contact as unknown as Record<string, unknown>));
</script>
{#if hasAny}
<section class="detail-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"
/>
</svg>
</div>
<h3 class="section-title">Social Media</h3>
</div>
<div class="social-links-grid">
{#each entries as { platform, value }}
{#if platform.hasLink && platform.buildUrl}
<a
href={platform.buildUrl(value)}
target="_blank"
rel="noopener noreferrer"
class="social-link"
>
<span class="social-badge">{platform.badge}</span>
<span class="social-link-text">{platform.name}</span>
</a>
{:else}
<span class="social-link social-link-static">
<span class="social-badge">{platform.badge}</span>
<span class="social-link-text">{value}</span>
</span>
{/if}
{/each}
</div>
</section>
{/if}
<style>
.detail-section {
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.875rem;
padding: 1rem;
}
.section-header {
display: flex;
align-items: center;
gap: 0.625rem;
padding-bottom: 0.625rem;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
margin-bottom: 0.625rem;
}
.section-icon {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
display: flex;
align-items: center;
justify-content: center;
}
.section-icon svg {
width: 1rem;
height: 1rem;
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.social-links-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.social-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
background: hsl(var(--color-muted) / 0.5);
border-radius: 0.5rem;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
}
.social-link:hover:not(.social-link-static) {
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
.social-link-static {
cursor: default;
}
.social-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
font-size: 0.625rem;
font-weight: 700;
flex-shrink: 0;
}
.social-link-text {
font-size: 0.8125rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 480px) {
.social-links-grid {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,369 @@
<script lang="ts">
interface Props {
linkedin: string;
twitter: string;
facebook: string;
instagram: string;
xing: string;
github: string;
youtube: string;
tiktok: string;
telegram: string;
whatsapp: string;
signal: string;
discord: string;
bluesky: string;
initiallyOpen?: boolean;
}
let {
linkedin = $bindable(''),
twitter = $bindable(''),
facebook = $bindable(''),
instagram = $bindable(''),
xing = $bindable(''),
github = $bindable(''),
youtube = $bindable(''),
tiktok = $bindable(''),
telegram = $bindable(''),
whatsapp = $bindable(''),
signal = $bindable(''),
discord = $bindable(''),
bluesky = $bindable(''),
initiallyOpen = false,
}: Props = $props();
let isOpen = $state(initiallyOpen);
// Auto-open if any field has data
$effect(() => {
if (
linkedin ||
twitter ||
facebook ||
instagram ||
xing ||
github ||
youtube ||
tiktok ||
telegram ||
whatsapp ||
signal ||
discord ||
bluesky
) {
isOpen = true;
}
});
</script>
<section class="form-section">
<button
type="button"
class="section-header section-header-toggle"
onclick={() => (isOpen = !isOpen)}
>
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"
/>
</svg>
</div>
<h2 class="section-title">Social Media</h2>
<svg
class="chevron-icon"
class:chevron-open={isOpen}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if isOpen}
<div class="social-grid">
<div class="form-field">
<label for="linkedin" class="label social-label">
<span class="social-icon-label">in</span>
LinkedIn
</label>
<input
id="linkedin"
type="url"
bind:value={linkedin}
class="input"
placeholder="https://linkedin.com/in/..."
/>
</div>
<div class="form-field">
<label for="twitter" class="label social-label">
<span class="social-icon-label">X</span>
Twitter / X
</label>
<input
id="twitter"
type="text"
bind:value={twitter}
class="input"
placeholder="@username"
/>
</div>
<div class="form-field">
<label for="facebook" class="label social-label">
<span class="social-icon-label">f</span>
Facebook
</label>
<input
id="facebook"
type="url"
bind:value={facebook}
class="input"
placeholder="https://facebook.com/..."
/>
</div>
<div class="form-field">
<label for="instagram" class="label social-label">
<span class="social-icon-label">ig</span>
Instagram
</label>
<input
id="instagram"
type="text"
bind:value={instagram}
class="input"
placeholder="@username"
/>
</div>
<div class="form-field">
<label for="xing" class="label social-label">
<span class="social-icon-label">xi</span>
Xing
</label>
<input
id="xing"
type="url"
bind:value={xing}
class="input"
placeholder="https://xing.com/profile/..."
/>
</div>
<div class="form-field">
<label for="github" class="label social-label">
<span class="social-icon-label">gh</span>
GitHub
</label>
<input id="github" type="text" bind:value={github} class="input" placeholder="username" />
</div>
<div class="form-field">
<label for="youtube" class="label social-label">
<span class="social-icon-label">yt</span>
YouTube
</label>
<input
id="youtube"
type="url"
bind:value={youtube}
class="input"
placeholder="https://youtube.com/@..."
/>
</div>
<div class="form-field">
<label for="tiktok" class="label social-label">
<span class="social-icon-label">tt</span>
TikTok
</label>
<input id="tiktok" type="text" bind:value={tiktok} class="input" placeholder="@username" />
</div>
<div class="form-field">
<label for="telegram" class="label social-label">
<span class="social-icon-label">tg</span>
Telegram
</label>
<input
id="telegram"
type="text"
bind:value={telegram}
class="input"
placeholder="@username"
/>
</div>
<div class="form-field">
<label for="whatsapp" class="label social-label">
<span class="social-icon-label">wa</span>
WhatsApp
</label>
<input id="whatsapp" type="tel" bind:value={whatsapp} class="input" placeholder="+49..." />
</div>
<div class="form-field">
<label for="signal" class="label social-label">
<span class="social-icon-label">sg</span>
Signal
</label>
<input id="signal" type="tel" bind:value={signal} class="input" placeholder="+49..." />
</div>
<div class="form-field">
<label for="discord" class="label social-label">
<span class="social-icon-label">dc</span>
Discord
</label>
<input
id="discord"
type="text"
bind:value={discord}
class="input"
placeholder="username#1234"
/>
</div>
<div class="form-field">
<label for="bluesky" class="label social-label">
<span class="social-icon-label">bs</span>
Bluesky
</label>
<input
id="bluesky"
type="text"
bind:value={bluesky}
class="input"
placeholder="@handle.bsky.social"
/>
</div>
</div>
{/if}
</section>
<style>
.form-section {
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 1rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.section-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
margin-bottom: 0.25rem;
}
.section-header-toggle {
cursor: pointer;
background: none;
border: none;
width: 100%;
text-align: left;
padding: 0;
border-bottom: none;
margin-bottom: 0;
}
.section-icon {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.section-icon svg {
width: 1.125rem;
height: 1.125rem;
}
.section-title {
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
flex: 1;
}
.chevron-icon {
width: 1.25rem;
height: 1.25rem;
color: hsl(var(--color-muted-foreground));
transition: transform 0.2s ease;
flex-shrink: 0;
}
.chevron-open {
transform: rotate(180deg);
}
.social-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (max-width: 480px) {
.social-grid {
grid-template-columns: 1fr;
}
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.label {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.social-label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.social-icon-label {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
font-size: 0.625rem;
font-weight: 700;
text-transform: lowercase;
}
.input {
width: 100%;
padding: 0.75rem 1rem;
border: 1.5px solid hsl(var(--color-border));
border-radius: 0.625rem;
background: hsl(var(--color-input));
color: hsl(var(--color-foreground));
font-size: 0.9375rem;
transition: all 0.2s ease;
}
.input:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
.input::placeholder {
color: hsl(var(--color-muted-foreground) / 0.6);
}
</style>

View file

@ -347,15 +347,15 @@
{node.name}
</text>
<!-- Company label -->
{#if node.company}
<!-- Company label (uses subtitle field) -->
{#if node.subtitle}
<text
y={isSelected ? 56 : 50}
class="node-company"
text-anchor="middle"
font-size="9"
>
{node.company}
{node.subtitle}
</text>
{/if}
</g>

View file

@ -5,6 +5,7 @@
*/
import ContactCardSkeleton from './ContactCardSkeleton.svelte';
import { calculateFadeOpacity } from './utils';
interface Props {
/** Number of skeleton cards to show */
@ -16,17 +17,11 @@
}
let { count = 8, fadeEffect = true, minOpacity = 0.4 }: Props = $props();
function calculateOpacity(index: number): number {
if (!fadeEffect) return 1;
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
return Math.max(minOpacity, 1 - index * fadeStep);
}
</script>
<div class="contact-grid-skeleton" role="status" aria-label="Kontakte werden geladen...">
{#each Array(count) as _, i}
<ContactCardSkeleton opacity={calculateOpacity(i)} />
<ContactCardSkeleton opacity={fadeEffect ? calculateFadeOpacity(i, count, minOpacity) : 1} />
{/each}
</div>

View file

@ -5,6 +5,7 @@
*/
import ContactRowSkeleton from './ContactRowSkeleton.svelte';
import { calculateFadeOpacity } from './utils';
interface Props {
/** Number of skeleton rows to show */
@ -16,16 +17,10 @@
}
let { count = 8, fadeEffect = true, minOpacity = 0.3 }: Props = $props();
function calculateOpacity(index: number): number {
if (!fadeEffect) return 1;
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
return Math.max(minOpacity, 1 - index * fadeStep);
}
</script>
<div class="space-y-2" role="status" aria-label="Kontakte werden geladen...">
{#each Array(count) as _, i}
<ContactRowSkeleton opacity={calculateOpacity(i)} />
<ContactRowSkeleton opacity={fadeEffect ? calculateFadeOpacity(i, count, minOpacity) : 1} />
{/each}
</div>

View file

@ -6,6 +6,7 @@
import { SkeletonBox } from '@manacore/shared-ui';
import DuplicateGroupSkeleton from './DuplicateGroupSkeleton.svelte';
import { calculateFadeOpacity } from './utils';
interface Props {
/** Number of duplicate groups to show */
@ -17,12 +18,6 @@
}
let { count = 3, fadeEffect = true, minOpacity = 0.4 }: Props = $props();
function calculateOpacity(index: number): number {
if (!fadeEffect) return 1;
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
return Math.max(minOpacity, 1 - index * fadeStep);
}
</script>
<div class="space-y-6" role="status" aria-label="Duplikate werden geladen...">
@ -39,7 +34,10 @@
<!-- Duplicate groups skeleton -->
<div class="space-y-4">
{#each Array(count) as _, i}
<DuplicateGroupSkeleton contactCount={2 + (i % 2)} opacity={calculateOpacity(i)} />
<DuplicateGroupSkeleton
contactCount={2 + (i % 2)}
opacity={fadeEffect ? calculateFadeOpacity(i, count, minOpacity) : 1}
/>
{/each}
</div>
</div>

View file

@ -4,6 +4,7 @@
*/
import TagCardSkeleton from './TagCardSkeleton.svelte';
import { calculateFadeOpacity } from './utils';
interface Props {
/** Number of skeleton cards to show */
@ -15,17 +16,11 @@
}
let { count = 6, fadeEffect = true, minOpacity = 0.4 }: Props = $props();
function calculateOpacity(index: number): number {
if (!fadeEffect) return 1;
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
return Math.max(minOpacity, 1 - index * fadeStep);
}
</script>
<div class="tag-grid-skeleton" role="status" aria-label="Tags werden geladen...">
{#each Array(count) as _, i}
<TagCardSkeleton opacity={calculateOpacity(i)} />
<TagCardSkeleton opacity={fadeEffect ? calculateFadeOpacity(i, count, minOpacity) : 1} />
{/each}
</div>

View file

@ -5,6 +5,9 @@
* Built on top of @manacore/shared-ui skeleton primitives.
*/
// Utilities
export { calculateFadeOpacity } from './utils';
// Contact List/Grid Skeletons
export { default as ContactRowSkeleton } from './ContactRowSkeleton.svelte';
export { default as ContactListSkeleton } from './ContactListSkeleton.svelte';
@ -15,10 +18,6 @@ export { default as ContactGridSkeleton } from './ContactGridSkeleton.svelte';
export { default as TagCardSkeleton } from './TagCardSkeleton.svelte';
export { default as TagGridSkeleton } from './TagGridSkeleton.svelte';
// Favorite Skeletons
export { default as FavoriteCardSkeleton } from './FavoriteCardSkeleton.svelte';
export { default as FavoriteGridSkeleton } from './FavoriteGridSkeleton.svelte';
// Duplicate Skeletons
export { default as DuplicateGroupSkeleton } from './DuplicateGroupSkeleton.svelte';
export { default as DuplicateListSkeleton } from './DuplicateListSkeleton.svelte';

View file

@ -0,0 +1,19 @@
/**
* Skeleton utility functions
*/
/**
* Calculate opacity for cascading fade effect in skeleton lists
* @param index Current item index
* @param count Total number of items
* @param minOpacity Minimum opacity (default: 0.3)
* @returns Opacity value between minOpacity and 1
*/
export function calculateFadeOpacity(
index: number,
count: number,
minOpacity: number = 0.3
): number {
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
return Math.max(minOpacity, 1 - index * fadeStep);
}

View file

@ -5,6 +5,8 @@
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
import { isSidebarMode } from '$lib/stores/navigation';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import { contactsSettings } from '$lib/stores/settings.svelte';
import AlphabetNavContextMenu from '$lib/components/AlphabetNavContextMenu.svelte';
interface Props {
contacts: Contact[];
@ -36,12 +38,27 @@
contactsFilterStore.toggleAlphabetNav();
}
// Context menu for alphabet nav
let alphabetContextMenu: AlphabetNavContextMenu;
function handleAlphabetContextMenu(e: MouseEvent) {
e.preventDefault();
alphabetContextMenu?.show(e.clientX, e.clientY);
}
function handleCheckboxClick(e: MouseEvent, id: string) {
e.stopPropagation();
onToggleSelection?.(id);
}
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
// Alphabet with optional reverse order
let alphabet = $derived.by(() => {
let letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
if (contactsSettings.alphabetNavReverseOrder) {
letters = letters.reverse();
}
return letters;
});
function getInitials(contact: Contact) {
const first = contact.firstName?.[0] || '';
@ -267,13 +284,15 @@
class:sidebar-mode={$isSidebarMode}
class:toolbar-expanded={isToolbarExpanded}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<button
onclick={toggleAlphabetNav}
oncontextmenu={handleAlphabetContextMenu}
class="alphabet-fab"
class:active={!isAlphabetNavCollapsed}
title={isAlphabetNavCollapsed
? 'Alphabet-Navigation öffnen'
: 'Alphabet-Navigation schließen'}
? 'Alphabet-Navigation öffnen (Rechtsklick für Optionen)'
: 'Alphabet-Navigation schließen (Rechtsklick für Optionen)'}
>
<svg class="fab-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if isAlphabetNavCollapsed}
@ -301,27 +320,44 @@
class:sidebar-mode={$isSidebarMode}
class:toolbar-expanded={isToolbarExpanded}
>
<div class="alphabet-nav-container">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="alphabet-nav-container"
class:compact={contactsSettings.alphabetNavCompact}
oncontextmenu={handleAlphabetContextMenu}
>
{#each alphabet as letter}
{@const isActive = availableLetters.includes(letter)}
{@const shouldHide = contactsSettings.alphabetNavHideInactive && !isActive}
{#if !shouldHide}
<button
type="button"
class="alphabet-nav-btn"
class:active={isActive}
class:disabled={!isActive}
class:compact={contactsSettings.alphabetNavCompact}
onclick={() => isActive && scrollToLetter(letter)}
disabled={!isActive}
>
{letter}
</button>
{/if}
{/each}
{#if contactsSettings.alphabetNavShowHash && availableLetters.includes('#')}
<button
type="button"
class="alphabet-nav-btn"
class:active={availableLetters.includes(letter)}
class:disabled={!availableLetters.includes(letter)}
onclick={() => availableLetters.includes(letter) && scrollToLetter(letter)}
disabled={!availableLetters.includes(letter)}
class="alphabet-nav-btn active"
class:compact={contactsSettings.alphabetNavCompact}
onclick={() => scrollToLetter('#')}
>
{letter}
</button>
{/each}
{#if availableLetters.includes('#')}
<button type="button" class="alphabet-nav-btn active" onclick={() => scrollToLetter('#')}>
#
</button>
{/if}
</div>
</div>
{/if}
<AlphabetNavContextMenu bind:this={alphabetContextMenu} />
</div>
<style>
@ -329,6 +365,8 @@
display: block;
position: relative;
padding-bottom: 10rem; /* Space for fixed alphabet nav + InputBar */
max-width: 600px;
margin: 0 auto;
}
.alphabet-sections {
@ -630,6 +668,17 @@
background: transparent;
}
/* Compact mode for alphabet nav */
.alphabet-nav-container.compact {
padding: 0.375rem 1rem;
}
.alphabet-nav-btn.compact {
min-width: 36px;
height: 40px;
font-size: 0.9375rem;
}
/* New Contact Card */
.new-contact-section {
margin-bottom: 1rem;
@ -668,10 +717,18 @@
.alphabet-fab-container {
position: fixed;
bottom: calc(70px + 9px + env(safe-area-inset-bottom, 0px)); /* Align with InputBar */
left: calc(50% - 350px - 70px); /* Left of InputBar (max-width 700px / 2 + gap) */
/* InputBar is 450px when toolbar is shown: left edge at 50% - 225px, minus gap and fab width */
left: calc(50% - 225px - 8px - 54px);
z-index: 49; /* Below InputBar (90) and ExpandableToolbar FAB (91), above alphabet-nav (48) */
pointer-events: none;
transition: bottom 0.2s ease;
transition:
bottom 0.2s ease,
left 0.2s ease;
}
/* Sidebar mode - InputBar is 700px wide, position accordingly */
.alphabet-fab-container.sidebar-mode {
left: calc(50% - 350px - 8px - 54px); /* Left of 700px InputBar */
}
/* Responsive positioning for FAB */

View file

@ -246,6 +246,8 @@
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-base);
/* Equal height cards */
min-height: 280px;
}
.grid-card:hover {
@ -318,7 +320,7 @@
.grid-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
margin-top: auto; /* Push to bottom of card */
padding-top: 1rem;
border-top: 1px solid hsl(var(--border));
width: 100%;

View file

@ -0,0 +1,257 @@
<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>

View file

@ -0,0 +1,168 @@
/**
* Social Media Platform Configuration
* Centralized config for all social media platforms used in the contacts app
*/
export interface SocialPlatform {
/** Unique identifier matching Contact field name */
id: string;
/** Display name */
name: string;
/** Short badge label */
badge: string;
/** Input type for form fields */
inputType: 'url' | 'text' | 'tel';
/** Placeholder text for form input */
placeholder: string;
/** Whether this platform has a clickable link */
hasLink: boolean;
/** Function to build the full URL from a value */
buildUrl?: (value: string) => string;
}
/**
* All supported social media platforms
*/
export const SOCIAL_PLATFORMS: SocialPlatform[] = [
{
id: 'linkedin',
name: 'LinkedIn',
badge: 'in',
inputType: 'url',
placeholder: 'https://linkedin.com/in/...',
hasLink: true,
buildUrl: (v) => (v.startsWith('http') ? v : `https://linkedin.com/in/${v}`),
},
{
id: 'twitter',
name: 'Twitter / X',
badge: 'X',
inputType: 'text',
placeholder: '@username',
hasLink: true,
buildUrl: (v) => (v.startsWith('http') ? v : `https://x.com/${v.replace('@', '')}`),
},
{
id: 'facebook',
name: 'Facebook',
badge: 'f',
inputType: 'url',
placeholder: 'https://facebook.com/...',
hasLink: true,
buildUrl: (v) => (v.startsWith('http') ? v : `https://facebook.com/${v}`),
},
{
id: 'instagram',
name: 'Instagram',
badge: 'ig',
inputType: 'text',
placeholder: '@username',
hasLink: true,
buildUrl: (v) => (v.startsWith('http') ? v : `https://instagram.com/${v.replace('@', '')}`),
},
{
id: 'xing',
name: 'Xing',
badge: 'xi',
inputType: 'url',
placeholder: 'https://xing.com/profile/...',
hasLink: true,
buildUrl: (v) => (v.startsWith('http') ? v : `https://xing.com/profile/${v}`),
},
{
id: 'github',
name: 'GitHub',
badge: 'gh',
inputType: 'text',
placeholder: 'username',
hasLink: true,
buildUrl: (v) => (v.startsWith('http') ? v : `https://github.com/${v}`),
},
{
id: 'youtube',
name: 'YouTube',
badge: 'yt',
inputType: 'url',
placeholder: 'https://youtube.com/@...',
hasLink: true,
buildUrl: (v) => (v.startsWith('http') ? v : `https://youtube.com/@${v}`),
},
{
id: 'tiktok',
name: 'TikTok',
badge: 'tt',
inputType: 'text',
placeholder: '@username',
hasLink: true,
buildUrl: (v) => (v.startsWith('http') ? v : `https://tiktok.com/@${v.replace('@', '')}`),
},
{
id: 'telegram',
name: 'Telegram',
badge: 'tg',
inputType: 'text',
placeholder: '@username',
hasLink: true,
buildUrl: (v) => `https://t.me/${v.replace('@', '')}`,
},
{
id: 'whatsapp',
name: 'WhatsApp',
badge: 'wa',
inputType: 'tel',
placeholder: '+49...',
hasLink: true,
buildUrl: (v) => `https://wa.me/${v.replace(/[^0-9]/g, '')}`,
},
{
id: 'signal',
name: 'Signal',
badge: 'sg',
inputType: 'tel',
placeholder: '+49...',
hasLink: false,
},
{
id: 'discord',
name: 'Discord',
badge: 'dc',
inputType: 'text',
placeholder: 'username#1234',
hasLink: false,
},
{
id: 'bluesky',
name: 'Bluesky',
badge: 'bs',
inputType: 'text',
placeholder: '@handle.bsky.social',
hasLink: true,
buildUrl: (v) => (v.startsWith('http') ? v : `https://bsky.app/profile/${v.replace('@', '')}`),
},
];
/**
* Get platform config by ID
*/
export function getPlatform(id: string): SocialPlatform | undefined {
return SOCIAL_PLATFORMS.find((p) => p.id === id);
}
/**
* Check if a contact has any social media data
*/
export function hasSocialMedia(contact: Record<string, unknown>): boolean {
return SOCIAL_PLATFORMS.some((p) => !!contact[p.id]);
}
/**
* Get all social media entries for a contact
*/
export function getSocialMediaEntries(
contact: Record<string, unknown>
): Array<{ platform: SocialPlatform; value: string }> {
return SOCIAL_PLATFORMS.filter((p) => !!contact[p.id]).map((platform) => ({
platform,
value: contact[platform.id] as string,
}));
}

View file

@ -4,8 +4,7 @@
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001';
import { MANA_AUTH_URL } from '$lib/api/config';
export const feedbackService = createFeedbackService({
apiUrl: MANA_AUTH_URL,

View file

@ -6,10 +6,7 @@
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Initialize Mana Core Auth only on the client side
// TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env when available
const MANA_AUTH_URL = 'http://localhost:3001';
import { MANA_AUTH_URL } from '$lib/api/config';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;

View file

@ -62,8 +62,18 @@ function saveState(state: ContactsFilterState) {
// Reactive state
let state = $state<ContactsFilterState>(DEFAULT_STATE);
// Generic update helper
function update<K extends keyof ContactsFilterState>(
key: K,
value: ContactsFilterState[K],
persist = true
) {
state = { ...state, [key]: value };
if (persist) saveState(state);
}
export const contactsFilterStore = {
// Getters
// Getters - Required for Svelte 5 reactivity
get sortField() {
return state.sortField;
},
@ -90,57 +100,23 @@ export const contactsFilterStore = {
},
// Setters
setSortField(value: SortField) {
state = { ...state, sortField: value };
saveState(state);
},
setContactFilter(value: ContactFilter) {
state = { ...state, contactFilter: value };
saveState(state);
},
setBirthdayFilter(value: BirthdayFilter) {
state = { ...state, birthdayFilter: value };
saveState(state);
},
setSelectedTagId(value: string | null) {
state = { ...state, selectedTagId: value };
saveState(state);
},
setSelectedCompany(value: string | null) {
state = { ...state, selectedCompany: value };
saveState(state);
},
setToolbarCollapsed(value: boolean) {
state = { ...state, isToolbarCollapsed: value };
saveState(state);
},
setSortField: (value: SortField) => update('sortField', value),
setContactFilter: (value: ContactFilter) => update('contactFilter', value),
setBirthdayFilter: (value: BirthdayFilter) => update('birthdayFilter', value),
setSelectedTagId: (value: string | null) => update('selectedTagId', value),
setSelectedCompany: (value: string | null) => update('selectedCompany', value),
setToolbarCollapsed: (value: boolean) => update('isToolbarCollapsed', value),
setAlphabetNavCollapsed: (value: boolean) => update('isAlphabetNavCollapsed', value),
setSearchQuery: (value: string) => update('searchQuery', value, false),
toggleToolbar() {
state = { ...state, isToolbarCollapsed: !state.isToolbarCollapsed };
saveState(state);
},
setAlphabetNavCollapsed(value: boolean) {
state = { ...state, isAlphabetNavCollapsed: value };
saveState(state);
update('isToolbarCollapsed', !state.isToolbarCollapsed);
},
toggleAlphabetNav() {
state = { ...state, isAlphabetNavCollapsed: !state.isAlphabetNavCollapsed };
saveState(state);
update('isAlphabetNavCollapsed', !state.isAlphabetNavCollapsed);
},
setSearchQuery(value: string) {
state = { ...state, searchQuery: value };
// Don't persist search query to localStorage
},
// Reset filters (but not toolbar state)
resetFilters() {
state = {
...state,
@ -153,7 +129,6 @@ export const contactsFilterStore = {
saveState(state);
},
// Initialize from localStorage
initialize() {
if (!browser) return;
state = loadState();

View file

@ -8,7 +8,7 @@ import { browser } from '$app/environment';
// Settings types
export type ContactSortBy = 'name' | 'company' | 'created' | 'updated';
export type ContactSortOrder = 'asc' | 'desc';
export type ContactView = 'grid' | 'alphabet';
export type ContactView = 'grid' | 'alphabet' | 'network';
export type DateFormat = 'dd.MM.yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd';
export interface ContactsAppSettings {
@ -55,6 +55,16 @@ export interface ContactsAppSettings {
privacyMode: boolean;
/** Require confirmation before sharing contact */
confirmBeforeSharing: boolean;
// Alphabet Navigation Settings
/** Hide letters that have no contacts */
alphabetNavHideInactive: boolean;
/** Use compact/smaller alphabet buttons */
alphabetNavCompact: boolean;
/** Reverse letter order (Z-A instead of A-Z) */
alphabetNavReverseOrder: boolean;
/** Show # symbol for non-letter names */
alphabetNavShowHash: boolean;
}
const DEFAULT_SETTINGS: ContactsAppSettings = {
@ -84,6 +94,12 @@ const DEFAULT_SETTINGS: ContactsAppSettings = {
// Privacy
privacyMode: false,
confirmBeforeSharing: true,
// Alphabet Navigation
alphabetNavHideInactive: false,
alphabetNavCompact: false,
alphabetNavReverseOrder: false,
alphabetNavShowHash: true,
};
const STORAGE_KEY = 'contacts-settings';
@ -187,6 +203,20 @@ export const contactsSettings = {
return settings.confirmBeforeSharing;
},
// Alphabet Navigation
get alphabetNavHideInactive() {
return settings.alphabetNavHideInactive;
},
get alphabetNavCompact() {
return settings.alphabetNavCompact;
},
get alphabetNavReverseOrder() {
return settings.alphabetNavReverseOrder;
},
get alphabetNavShowHash() {
return settings.alphabetNavShowHash;
},
/**
* Initialize settings from localStorage
*/

View file

@ -9,8 +9,7 @@
import { createUserSettingsStore } from '@manacore/shared-theme';
import { authStore } from './auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001';
import { MANA_AUTH_URL } from '$lib/api/config';
export const userSettings = createUserSettingsStore({
appId: 'contacts',

View file

@ -10,13 +10,20 @@ export type ViewMode = ContactView;
const STORAGE_KEY = 'contacts-view-mode';
// Valid view modes
const VALID_MODES: ViewMode[] = ['grid', 'alphabet', 'network'];
function isValidMode(mode: string | null): mode is ViewMode {
return mode !== null && VALID_MODES.includes(mode as ViewMode);
}
// Get initial mode: current session preference > settings default > 'alphabet'
function getInitialMode(): ViewMode {
if (!browser) return 'alphabet';
// First check if there's a session-specific preference
const sessionMode = sessionStorage.getItem(STORAGE_KEY);
if (sessionMode === 'grid' || sessionMode === 'alphabet') {
if (isValidMode(sessionMode)) {
return sessionMode;
}
@ -57,7 +64,7 @@ export const viewModeStore = {
// Check if there's a session preference
const sessionMode = sessionStorage.getItem(STORAGE_KEY);
if (sessionMode === 'grid' || sessionMode === 'alphabet') {
if (isValidMode(sessionMode)) {
mode = sessionMode;
} else {
// Use default from settings

View file

@ -13,7 +13,6 @@
PillNavItem,
PillDropdownItem,
QuickInputItem,
QuickAction,
CreatePreview,
} from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
@ -47,8 +46,6 @@
formatParsedContactPreview,
} from '$lib/utils/contact-parser';
import ContactsToolbar from '$lib/components/ContactsToolbar.svelte';
import NetworkToolbar from '$lib/components/NetworkToolbar.svelte';
import { networkStore } from '$lib/stores/network.svelte';
// Tags state for Quick-Create
let availableTags = $state<{ id: string; name: string }[]>([]);
@ -77,23 +74,16 @@
// Show toolbar only on main contacts page
const showContactsToolbar = $derived($page.url.pathname === '/' && !isSidebarMode);
// Show network toolbar only on network page
const showNetworkToolbar = $derived($page.url.pathname === '/network' && !isSidebarMode);
// Check if any toolbar is expanded
const isAnyToolbarExpanded = $derived(
(showContactsToolbar && !contactsFilterStore.isToolbarCollapsed) ||
(showNetworkToolbar && !networkStore.isToolbarCollapsed)
// Check if toolbar is expanded
const isToolbarExpanded = $derived(
showContactsToolbar && !contactsFilterStore.isToolbarCollapsed
);
// Dynamic bottom offset based on toolbar state
const inputBarBottomOffset = $derived(
isSidebarMode ? '0px' : isAnyToolbarExpanded ? '140px' : '70px'
isSidebarMode ? '0px' : isToolbarExpanded ? '140px' : '70px'
);
// Show FAB when any toolbar is active
const showToolbarFab = $derived(showContactsToolbar || showNetworkToolbar);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
@ -147,9 +137,7 @@
const baseNavItems: PillNavItem[] = [
{ href: '/', label: 'Kontakte', icon: 'users' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
{ href: '/statistics', label: 'Statistiken', icon: 'bar-chart-3' },
{ href: '/network', label: 'Netzwerk', icon: 'share-2' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
{ href: '/help', label: 'Hilfe', icon: 'help-circle' },
@ -270,13 +258,6 @@
});
}
// QuickInputBar quick actions
const quickActions: QuickAction[] = [
{ id: 'favorites', label: 'Favoriten', icon: 'heart', href: '/favorites' },
{ id: 'tags', label: 'Tags', icon: 'tag', href: '/tags' },
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
];
onMount(async () => {
// Redirect to login if not authenticated
if (!authStore.isAuthenticated) {
@ -386,7 +367,6 @@
onSearch={handleSearch}
onSelect={handleSelect}
onSearchChange={(query) => contactsFilterStore.setSearchQuery(query)}
{quickActions}
placeholder="Neuer Kontakt oder suchen..."
emptyText="Keine Kontakte gefunden"
searchingText="Suche..."
@ -394,21 +374,15 @@
onParseCreate={handleParseCreate}
createText="Erstellen"
appIcon="contacts"
primaryColor="#3b82f6"
autoFocus={true}
bottomOffset={inputBarBottomOffset}
hasFabRight={showToolbarFab}
hasFabRight={showContactsToolbar}
/>
<!-- Contacts Toolbar (FAB + expandable bar) - only on main page -->
{#if showContactsToolbar}
<ContactsToolbar {isSidebarMode} contacts={contactsStore.contacts} />
{/if}
<!-- Network Toolbar (FAB + expandable bar) - only on network page -->
{#if showNetworkToolbar}
<NetworkToolbar {isSidebarMode} />
{/if}
</div>
</SplitPaneContainer>
@ -444,9 +418,7 @@
}
.content-wrapper {
max-width: 900px;
margin-left: auto;
margin-right: auto;
/* No max-width - let individual views control their own width */
padding: 1rem;
}
@ -461,4 +433,23 @@
padding: 2rem;
}
}
/* Adjust InputBar when toolbar elements (view-mode-pill + FAB) are visible */
/* Pill left edge is at: 50% - 238px from right edge of viewport */
/* This means from center, there's 238px to the pill's left edge */
/* For a centered InputBar with max-width W, right edge is at: center + W/2 */
/* We need: center + W/2 < center + 238 - 12px gap, so W/2 < 226, W < 452px */
:global(.quick-input-bar.has-fab-right .input-container) {
max-width: 450px;
}
/* On smaller screens (<900px), the FAB + pill move to right: 1rem position */
/* So we need fixed padding instead */
@media (max-width: 900px) {
:global(.quick-input-bar.has-fab-right .input-container) {
max-width: calc(100% - 200px); /* ~120px pill + 8px + 54px FAB + 18px gap */
margin-left: 0;
margin-right: auto;
}
}
</style>

View file

@ -25,9 +25,9 @@
// Options for selects
const viewOptions = [
{ value: 'list', label: 'Liste' },
{ value: 'grid', label: 'Kacheln' },
{ value: 'alphabet', label: 'Alphabetisch' },
{ value: 'network', label: 'Netzwerk' },
];
const sortByOptions = [
@ -63,7 +63,6 @@
const startPageLabels: Record<string, string> = {
'nav.contacts': 'Kontakte',
'nav.groups': 'Gruppen',
'nav.favorites': 'Favoriten',
};
function translateLabel(key: string): string {

View file

@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
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/tags';
import { tagsApi } from '$lib/api/contacts';
import {
StatsGrid,
ActivityHeatmap,
@ -12,7 +12,7 @@
StatisticsSkeleton,
type StatItem,
} from '@manacore/shared-ui';
import { BarChart3, Users, Star, UserPlus, Cake, Mail, CheckCircle } from 'lucide-svelte';
import { BarChart3, Users, Star, UserPlus, Cake, Mail, CircleCheck } from 'lucide-svelte';
let loading = $state(true);
@ -22,47 +22,48 @@
});
// 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,
icon: Users as unknown as Component,
variant: 'primary',
},
{
id: 'favorites',
label: 'Favoriten',
value: contactsStatisticsStore.favoriteContacts,
icon: Star,
icon: Star as unknown as Component,
variant: 'accent',
},
{
id: 'recentlyAdded',
label: 'Neu (7 Tage)',
value: contactsStatisticsStore.recentlyAdded,
icon: UserPlus,
icon: UserPlus as unknown as Component,
variant: 'success',
},
{
id: 'birthdays',
label: 'Geburtstage',
value: contactsStatisticsStore.birthdaysThisMonth,
icon: Cake,
icon: Cake as unknown as Component,
variant: 'info',
},
{
id: 'withEmail',
label: 'Mit E-Mail',
value: contactsStatisticsStore.contactsWithEmail,
icon: Mail,
icon: Mail as unknown as Component,
variant: 'neutral',
},
{
id: 'completeness',
label: 'Vollständigkeit',
value: `${contactsStatisticsStore.completenessRate}%`,
icon: CheckCircle,
icon: CircleCheck as unknown as Component,
variant: contactsStatisticsStore.completenessRate >= 70 ? 'success' : 'danger',
},
]);
@ -76,8 +77,8 @@
// Fetch tags
try {
const tagsResult = await tagsApi.list();
contactsStatisticsStore.setTags(tagsResult);
const { tags } = await tagsApi.list();
contactsStatisticsStore.setTags(tags);
} catch (e) {
console.error('Failed to load tags:', e);
}

View file

@ -17,14 +17,14 @@
</script>
<svelte:head>
<title>{translations.title} | Contacts</title>
<title>{translations.titleForm} | Contacts</title>
</svelte:head>
<ForgotPasswordPage
appName="Contacts"
logo={ContactsLogo}
primaryColor="#3b82f6"
onResetPassword={handleResetPassword}
onForgotPassword={handleResetPassword}
{goto}
loginPath="/login"
lightBackground="#eff6ff"