mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
c7a9e88d13
commit
fc3129aaa5
34 changed files with 1808 additions and 1171 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
71
apps/contacts/apps/web/src/lib/api/client.ts
Normal file
71
apps/contacts/apps/web/src/lib/api/client.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
19
apps/contacts/apps/web/src/lib/components/skeletons/utils.ts
Normal file
19
apps/contacts/apps/web/src/lib/components/skeletons/utils.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
168
apps/contacts/apps/web/src/lib/config/social-media.ts
Normal file
168
apps/contacts/apps/web/src/lib/config/social-media.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue