mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(contacts): integrate contacts into Todo and Calendar apps
- Add ContactSelector, ContactBadge, ContactAvatar to shared-ui - Add ContactsClient API service to shared-auth - Add ContactReference, ContactSummary types to shared-types - Todo: Add assignee and involvedContacts to tasks with UI in TaskEditModal - Todo: Display contacts in TaskItem and KanbanTaskCard - Calendar: Add AttendeeSelector with RSVP status support - Calendar: Integrate attendees in EventForm - Calendar: Add task drag-drop to calendar views (Day/Week/MultiDay) - Contacts: Add ContactTasks component to show related tasks - Backend: Add findByContact endpoint to Todo task service - UI polish: glassmorphism styling, keyboard navigation, auto-focus 🤖 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
307f1ae22e
commit
0ecbf69ebc
50 changed files with 5791 additions and 53 deletions
|
|
@ -15,6 +15,7 @@
|
|||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"base64-js": "^1.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
204
packages/shared-auth/src/clients/contactsClient.ts
Normal file
204
packages/shared-auth/src/clients/contactsClient.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
/**
|
||||
* Contacts API Client for cross-app integration
|
||||
*
|
||||
* This client allows other apps (Todo, Calendar) to search and fetch contacts
|
||||
* from the Contacts app backend.
|
||||
*/
|
||||
|
||||
import type { ContactSummary } from '@manacore/shared-types';
|
||||
|
||||
export interface ContactsClientConfig {
|
||||
/** Base URL of the Contacts API (e.g., http://localhost:3015/api/v1) */
|
||||
apiUrl: string;
|
||||
/** Function to get the current auth token */
|
||||
getAuthToken: () => Promise<string | null>;
|
||||
/** Request timeout in ms (default: 5000) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ContactSearchOptions {
|
||||
/** Search query string */
|
||||
query?: string;
|
||||
/** Maximum number of results */
|
||||
limit?: number;
|
||||
/** Skip archived contacts */
|
||||
excludeArchived?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for accessing the Contacts API from other apps
|
||||
*/
|
||||
export class ContactsClient {
|
||||
private config: ContactsClientConfig;
|
||||
private available: boolean | null = null;
|
||||
|
||||
constructor(config: ContactsClientConfig) {
|
||||
this.config = {
|
||||
timeout: 5000,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Contacts API is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||
|
||||
const response = await fetch(`${this.config.apiUrl}/health`, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
this.available = response.ok;
|
||||
return this.available;
|
||||
} catch {
|
||||
this.available = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached availability status (call isAvailable() to refresh)
|
||||
*/
|
||||
getCachedAvailability(): boolean | null {
|
||||
return this.available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search contacts by query string
|
||||
*/
|
||||
async searchContacts(options: ContactSearchOptions = {}): Promise<ContactSummary[]> {
|
||||
const { query = '', limit = 20, excludeArchived = true } = options;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set('search', query);
|
||||
if (limit) params.set('limit', String(limit));
|
||||
if (excludeArchived) params.set('isArchived', 'false');
|
||||
|
||||
try {
|
||||
const response = (await this.fetchWithAuth(`/contacts?${params.toString()}`)) as {
|
||||
contacts?: Record<string, unknown>[];
|
||||
};
|
||||
return this.mapToContactSummaries(response.contacts || []);
|
||||
} catch (error) {
|
||||
console.error('[ContactsClient] Failed to search contacts:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single contact by ID
|
||||
*/
|
||||
async getContact(id: string): Promise<ContactSummary | null> {
|
||||
try {
|
||||
const response = (await this.fetchWithAuth(`/contacts/${id}`)) as {
|
||||
contact?: Record<string, unknown>;
|
||||
};
|
||||
if (response.contact) {
|
||||
return this.mapToContactSummary(response.contact);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[ContactsClient] Failed to get contact ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple contacts by IDs (batch fetch)
|
||||
*/
|
||||
async getContacts(ids: string[]): Promise<ContactSummary[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
// Contacts API doesn't have a batch endpoint, so we fetch individually
|
||||
// but with Promise.allSettled to handle partial failures gracefully
|
||||
const results = await Promise.allSettled(ids.map((id) => this.getContact(id)));
|
||||
|
||||
return results
|
||||
.filter(
|
||||
(result): result is PromiseFulfilledResult<ContactSummary | null> =>
|
||||
result.status === 'fulfilled' && result.value !== null
|
||||
)
|
||||
.map((result) => result.value as ContactSummary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal fetch with auth token
|
||||
*/
|
||||
private async fetchWithAuth(endpoint: string, options: RequestInit = {}): Promise<unknown> {
|
||||
const token = await this.config.getAuthToken();
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('Request timeout');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map API contact response to ContactSummary
|
||||
*/
|
||||
private mapToContactSummary(contact: Record<string, unknown>): ContactSummary {
|
||||
return {
|
||||
id: contact.id as string,
|
||||
displayName:
|
||||
(contact.displayName as string) ||
|
||||
[contact.firstName, contact.lastName].filter(Boolean).join(' ') ||
|
||||
(contact.email as string) ||
|
||||
'Unbekannt',
|
||||
firstName: contact.firstName as string | undefined,
|
||||
lastName: contact.lastName as string | undefined,
|
||||
email: contact.email as string | undefined,
|
||||
phone: (contact.phone as string) || (contact.mobile as string) || undefined,
|
||||
company: contact.company as string | undefined,
|
||||
photoUrl: contact.photoUrl as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map array of contacts to ContactSummary[]
|
||||
*/
|
||||
private mapToContactSummaries(contacts: Record<string, unknown>[]): ContactSummary[] {
|
||||
return contacts.map((c) => this.mapToContactSummary(c));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ContactsClient instance
|
||||
*/
|
||||
export function createContactsClient(config: ContactsClientConfig): ContactsClient {
|
||||
return new ContactsClient(config);
|
||||
}
|
||||
|
|
@ -70,6 +70,10 @@ export {
|
|||
} from './interceptors/fetchInterceptor';
|
||||
export type { FetchInterceptorConfig } from './interceptors/fetchInterceptor';
|
||||
|
||||
// Contacts client for cross-app integration
|
||||
export { ContactsClient, createContactsClient } from './clients/contactsClient';
|
||||
export type { ContactsClientConfig, ContactSearchOptions } from './clients/contactsClient';
|
||||
|
||||
/**
|
||||
* Initialize auth service with all adapters for web
|
||||
*
|
||||
|
|
|
|||
80
packages/shared-types/src/contact.ts
Normal file
80
packages/shared-types/src/contact.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Contact-related types for cross-app integration
|
||||
*
|
||||
* These types are used when referencing contacts from the Contacts app
|
||||
* in other apps like Todo and Calendar.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reference to a contact with cached display data.
|
||||
* Used for offline display when Contacts API is unavailable.
|
||||
*/
|
||||
export interface ContactReference {
|
||||
/** Contact ID from Contacts app */
|
||||
contactId: string;
|
||||
/** Cached display name */
|
||||
displayName: string;
|
||||
/** Cached email */
|
||||
email?: string;
|
||||
/** Cached photo URL */
|
||||
photoUrl?: string;
|
||||
/** Cached company name */
|
||||
company?: string;
|
||||
/** ISO timestamp when data was fetched (for cache invalidation) */
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of a contact from the Contacts API.
|
||||
* Contains essential fields for display in selectors and lists.
|
||||
*/
|
||||
export interface ContactSummary {
|
||||
id: string;
|
||||
displayName: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
photoUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual contact entry (when contact doesn't exist in Contacts app).
|
||||
* Used for calendar attendees who aren't in the user's contacts.
|
||||
*/
|
||||
export interface ManualContactEntry {
|
||||
/** Email address (required for manual entries) */
|
||||
email: string;
|
||||
/** Display name (optional) */
|
||||
name?: string;
|
||||
/** Indicates this is a manual entry, not from Contacts app */
|
||||
isManual: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for contact references that can be either
|
||||
* a real contact or a manual entry.
|
||||
*/
|
||||
export type ContactOrManual = ContactReference | ManualContactEntry;
|
||||
|
||||
/**
|
||||
* Helper to check if a contact entry is manual
|
||||
*/
|
||||
export function isManualContact(contact: ContactOrManual): contact is ManualContactEntry {
|
||||
return 'isManual' in contact && contact.isManual === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a ContactReference from a ContactSummary
|
||||
*/
|
||||
export function createContactReference(contact: ContactSummary): ContactReference {
|
||||
return {
|
||||
contactId: contact.id,
|
||||
displayName: contact.displayName,
|
||||
email: contact.email,
|
||||
photoUrl: contact.photoUrl,
|
||||
company: contact.company,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
|
@ -16,6 +16,9 @@ export * from './ui';
|
|||
// Common utility types
|
||||
export * from './common';
|
||||
|
||||
// Contact types for cross-app integration
|
||||
export * from './contact';
|
||||
|
||||
// API types
|
||||
export interface User {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-transition": "^3.0.0",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ export {
|
|||
// Feedback
|
||||
export { EmptyState } from './molecules';
|
||||
|
||||
// Contacts
|
||||
export { ContactAvatar, ContactBadge, ContactSelector } from './molecules';
|
||||
|
||||
// Layout
|
||||
export { ModalFooter, DataCard, PageHeader, KeyboardShortcutsPanel } from './molecules';
|
||||
|
||||
|
|
|
|||
100
packages/shared-ui/src/molecules/contacts/ContactAvatar.svelte
Normal file
100
packages/shared-ui/src/molecules/contacts/ContactAvatar.svelte
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<script lang="ts">
|
||||
import { User } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
/** Photo URL */
|
||||
photoUrl?: string | null;
|
||||
/** Display name (for initials fallback) */
|
||||
name?: string;
|
||||
/** Size in pixels */
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
/** Custom class */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { photoUrl, name = '', size = 'md', class: className = '' }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-5 h-5 text-[10px]',
|
||||
sm: 'w-6 h-6 text-xs',
|
||||
md: 'w-8 h-8 text-sm',
|
||||
lg: 'w-10 h-10 text-base',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
xs: 10,
|
||||
sm: 12,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
};
|
||||
|
||||
// Generate initials from name
|
||||
const initials = $derived.by(() => {
|
||||
if (!name) return '';
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length === 1) {
|
||||
return parts[0].charAt(0).toUpperCase();
|
||||
}
|
||||
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
|
||||
});
|
||||
|
||||
// Generate a consistent background color based on the name
|
||||
const bgColor = $derived.by(() => {
|
||||
if (!name) return 'bg-gray-400';
|
||||
const colors = [
|
||||
'bg-violet-500',
|
||||
'bg-blue-500',
|
||||
'bg-cyan-500',
|
||||
'bg-teal-500',
|
||||
'bg-green-500',
|
||||
'bg-amber-500',
|
||||
'bg-orange-500',
|
||||
'bg-rose-500',
|
||||
'bg-pink-500',
|
||||
'bg-indigo-500',
|
||||
];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if photoUrl}
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt={name || 'Kontakt'}
|
||||
class="
|
||||
{sizeClasses[size]}
|
||||
rounded-full object-cover
|
||||
{className}
|
||||
"
|
||||
/>
|
||||
{:else if initials}
|
||||
<div
|
||||
class="
|
||||
{sizeClasses[size]}
|
||||
{bgColor}
|
||||
rounded-full
|
||||
flex items-center justify-center
|
||||
text-white font-medium
|
||||
{className}
|
||||
"
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="
|
||||
{sizeClasses[size]}
|
||||
bg-gray-300 dark:bg-gray-600
|
||||
rounded-full
|
||||
flex items-center justify-center
|
||||
text-gray-500 dark:text-gray-400
|
||||
{className}
|
||||
"
|
||||
>
|
||||
<User size={iconSizes[size]} />
|
||||
</div>
|
||||
{/if}
|
||||
185
packages/shared-ui/src/molecules/contacts/ContactBadge.svelte
Normal file
185
packages/shared-ui/src/molecules/contacts/ContactBadge.svelte
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<script lang="ts">
|
||||
import { X } from '@manacore/shared-icons';
|
||||
import ContactAvatar from './ContactAvatar.svelte';
|
||||
import type {
|
||||
ContactReference,
|
||||
ManualContactEntry,
|
||||
ContactOrManual,
|
||||
} from '@manacore/shared-types';
|
||||
|
||||
interface Props {
|
||||
/** Contact to display */
|
||||
contact: ContactOrManual;
|
||||
/** Show remove button */
|
||||
removable?: boolean;
|
||||
/** Called when remove is clicked */
|
||||
onRemove?: () => void;
|
||||
/** Size variant */
|
||||
size?: 'sm' | 'md';
|
||||
/** Show email under name */
|
||||
showEmail?: boolean;
|
||||
}
|
||||
|
||||
let { contact, removable = false, onRemove, size = 'md', showEmail = false }: Props = $props();
|
||||
|
||||
// Check if this is a manual entry
|
||||
const isManual = $derived('isManual' in contact && contact.isManual === true);
|
||||
|
||||
// Get display values
|
||||
const displayName = $derived(
|
||||
isManual
|
||||
? (contact as ManualContactEntry).name || (contact as ManualContactEntry).email
|
||||
: (contact as ContactReference).displayName
|
||||
);
|
||||
|
||||
const email = $derived(
|
||||
isManual ? (contact as ManualContactEntry).email : (contact as ContactReference).email
|
||||
);
|
||||
|
||||
const photoUrl = $derived(isManual ? undefined : (contact as ContactReference).photoUrl);
|
||||
|
||||
const avatarSizes = {
|
||||
sm: 'xs' as const,
|
||||
md: 'sm' as const,
|
||||
};
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="contact-badge"
|
||||
class:size-sm={size === 'sm'}
|
||||
class:size-md={size === 'md'}
|
||||
class:manual={isManual}
|
||||
>
|
||||
<ContactAvatar {photoUrl} name={displayName} size={avatarSizes[size]} />
|
||||
|
||||
<span class="contact-info">
|
||||
<span class="contact-name">{displayName}</span>
|
||||
{#if showEmail && email && email !== displayName}
|
||||
<span class="contact-email">{email}</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if removable}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove?.();
|
||||
}}
|
||||
class="remove-btn"
|
||||
aria-label="Entfernen"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.contact-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
border-radius: 9999px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .contact-badge {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
border-color: rgba(139, 92, 246, 0.25);
|
||||
}
|
||||
|
||||
.contact-badge:hover {
|
||||
background: rgba(139, 92, 246, 0.18);
|
||||
border-color: rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
:global(.dark) .contact-badge:hover {
|
||||
background: rgba(139, 92, 246, 0.22);
|
||||
border-color: rgba(139, 92, 246, 0.35);
|
||||
}
|
||||
|
||||
/* Manual entry variant (dashed border) */
|
||||
.contact-badge.manual {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
border: 1px dashed rgba(107, 114, 128, 0.3);
|
||||
}
|
||||
|
||||
:global(.dark) .contact-badge.manual {
|
||||
background: rgba(156, 163, 175, 0.12);
|
||||
border-color: rgba(156, 163, 175, 0.3);
|
||||
}
|
||||
|
||||
/* Size variants */
|
||||
.size-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.size-md {
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
:global(.dark) .contact-name {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.contact-email {
|
||||
font-size: 0.625rem;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
:global(.dark) .contact-email {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 0.125rem;
|
||||
padding: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .remove-btn {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .remove-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
711
packages/shared-ui/src/molecules/contacts/ContactSelector.svelte
Normal file
711
packages/shared-ui/src/molecules/contacts/ContactSelector.svelte
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
<script lang="ts">
|
||||
import { Plus, MagnifyingGlass, User, Envelope } from '@manacore/shared-icons';
|
||||
import ContactBadge from './ContactBadge.svelte';
|
||||
import ContactAvatar from './ContactAvatar.svelte';
|
||||
import type {
|
||||
ContactReference,
|
||||
ContactSummary,
|
||||
ManualContactEntry,
|
||||
ContactOrManual,
|
||||
createContactReference,
|
||||
} from '@manacore/shared-types';
|
||||
|
||||
interface Props {
|
||||
/** Currently selected contacts */
|
||||
selectedContacts: ContactOrManual[];
|
||||
/** Called when selection changes */
|
||||
onContactsChange: (contacts: ContactOrManual[]) => void;
|
||||
/** Function to search contacts (async) */
|
||||
onSearch: (query: string) => Promise<ContactSummary[]>;
|
||||
/** Allow manual email entry (for contacts not in system) */
|
||||
allowManualEntry?: boolean;
|
||||
/** Maximum contacts that can be selected */
|
||||
maxContacts?: number;
|
||||
/** Single select mode (only one contact allowed) */
|
||||
singleSelect?: boolean;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Add button label */
|
||||
addLabel?: string;
|
||||
/** Search placeholder */
|
||||
searchPlaceholder?: string;
|
||||
/** Loading state */
|
||||
loading?: boolean;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
/** Show "not available" message when contacts API is down */
|
||||
unavailableMessage?: string;
|
||||
/** Is contacts API available */
|
||||
isAvailable?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
selectedContacts,
|
||||
onContactsChange,
|
||||
onSearch,
|
||||
allowManualEntry = false,
|
||||
maxContacts,
|
||||
singleSelect = false,
|
||||
placeholder = 'Kontakt hinzufügen...',
|
||||
addLabel = 'Kontakt hinzufügen',
|
||||
searchPlaceholder = 'Name oder E-Mail suchen...',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
unavailableMessage = 'Kontakte nicht verfügbar',
|
||||
isAvailable = true,
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<ContactSummary[]>([]);
|
||||
let isSearching = $state(false);
|
||||
let showManualEntry = $state(false);
|
||||
let manualEmail = $state('');
|
||||
let manualName = $state('');
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||
let highlightedIndex = $state(-1);
|
||||
|
||||
// Focus search input when dropdown opens
|
||||
$effect(() => {
|
||||
if (isOpen && searchInputRef) {
|
||||
setTimeout(() => searchInputRef?.focus(), 0);
|
||||
highlightedIndex = -1;
|
||||
}
|
||||
});
|
||||
|
||||
// Reset highlighted index when results change
|
||||
$effect(() => {
|
||||
if (searchResults.length > 0) {
|
||||
highlightedIndex = -1;
|
||||
}
|
||||
});
|
||||
|
||||
const effectiveMax = $derived(singleSelect ? 1 : maxContacts);
|
||||
const canAddMore = $derived(!effectiveMax || selectedContacts.length < effectiveMax);
|
||||
|
||||
// Check if an email looks valid
|
||||
function isValidEmail(email: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
// Debounced search
|
||||
async function handleSearchInput(query: string) {
|
||||
searchQuery = query;
|
||||
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
if (!query.trim()) {
|
||||
searchResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
if (!isAvailable) return;
|
||||
|
||||
isSearching = true;
|
||||
try {
|
||||
const results = await onSearch(query);
|
||||
// Filter out already selected contacts
|
||||
const selectedIds = new Set(
|
||||
selectedContacts
|
||||
.filter((c): c is ContactReference => 'contactId' in c)
|
||||
.map((c) => c.contactId)
|
||||
);
|
||||
searchResults = results.filter((r) => !selectedIds.has(r.id));
|
||||
} catch (error) {
|
||||
console.error('Contact search failed:', error);
|
||||
searchResults = [];
|
||||
} finally {
|
||||
isSearching = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleSelectContact(contact: ContactSummary) {
|
||||
if (!canAddMore) return;
|
||||
|
||||
const reference: ContactReference = {
|
||||
contactId: contact.id,
|
||||
displayName: contact.displayName,
|
||||
email: contact.email,
|
||||
photoUrl: contact.photoUrl,
|
||||
company: contact.company,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (singleSelect) {
|
||||
onContactsChange([reference]);
|
||||
} else {
|
||||
onContactsChange([...selectedContacts, reference]);
|
||||
}
|
||||
|
||||
searchQuery = '';
|
||||
searchResults = [];
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function handleRemoveContact(index: number) {
|
||||
onContactsChange(selectedContacts.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function handleAddManualEntry() {
|
||||
if (!manualEmail.trim() || !isValidEmail(manualEmail)) return;
|
||||
|
||||
const entry: ManualContactEntry = {
|
||||
email: manualEmail.trim(),
|
||||
name: manualName.trim() || undefined,
|
||||
isManual: true,
|
||||
};
|
||||
|
||||
if (singleSelect) {
|
||||
onContactsChange([entry]);
|
||||
} else {
|
||||
onContactsChange([...selectedContacts, entry]);
|
||||
}
|
||||
|
||||
manualEmail = '';
|
||||
manualName = '';
|
||||
showManualEntry = false;
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.contact-selector-container')) {
|
||||
isOpen = false;
|
||||
showManualEntry = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
isOpen = false;
|
||||
showManualEntry = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchKeyDown(e: KeyboardEvent) {
|
||||
if (!isOpen || searchResults.length === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
highlightedIndex = Math.min(highlightedIndex + 1, searchResults.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
highlightedIndex = Math.max(highlightedIndex - 1, -1);
|
||||
} else if (e.key === 'Enter' && highlightedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
handleSelectContact(searchResults[highlightedIndex]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} onkeydown={handleKeyDown} />
|
||||
|
||||
<div class="contact-selector-container">
|
||||
<!-- Selected Contacts Display -->
|
||||
<div class="selected-contacts">
|
||||
{#each selectedContacts as contact, index (index)}
|
||||
<ContactBadge {contact} removable onRemove={() => handleRemoveContact(index)} />
|
||||
{/each}
|
||||
|
||||
{#if canAddMore && !disabled}
|
||||
<button type="button" onclick={() => (isOpen = !isOpen)} class="add-button" {disabled}>
|
||||
<Plus size={14} weight="bold" />
|
||||
<span>{addLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
{#if isOpen}
|
||||
<div class="dropdown">
|
||||
{#if !isAvailable}
|
||||
<!-- Unavailable State -->
|
||||
<div class="unavailable-state">
|
||||
<User size={24} />
|
||||
<p>{unavailableMessage}</p>
|
||||
{#if allowManualEntry}
|
||||
<button type="button" onclick={() => (showManualEntry = true)} class="manual-link">
|
||||
Manuell hinzufügen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Search Input -->
|
||||
<div class="search-section">
|
||||
<div class="search-input-wrapper">
|
||||
<MagnifyingGlass size={16} class="search-icon" />
|
||||
<input
|
||||
bind:this={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
oninput={(e) => handleSearchInput(e.currentTarget.value)}
|
||||
onkeydown={handleSearchKeyDown}
|
||||
placeholder={searchPlaceholder}
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<div class="results-list">
|
||||
{#if isSearching || loading}
|
||||
<div class="empty-state">Suche...</div>
|
||||
{:else if searchResults.length > 0}
|
||||
{#each searchResults as contact, index (contact.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelectContact(contact)}
|
||||
class="result-item"
|
||||
class:highlighted={index === highlightedIndex}
|
||||
>
|
||||
<ContactAvatar photoUrl={contact.photoUrl} name={contact.displayName} size="md" />
|
||||
<div class="result-info">
|
||||
<div class="result-name">{contact.displayName}</div>
|
||||
{#if contact.email}
|
||||
<div class="result-detail">{contact.email}</div>
|
||||
{/if}
|
||||
{#if contact.company}
|
||||
<div class="result-detail">{contact.company}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if searchQuery.trim()}
|
||||
<div class="empty-state">Kein Kontakt gefunden</div>
|
||||
{:else}
|
||||
<div class="empty-state">Name oder E-Mail eingeben...</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Manual Entry Option -->
|
||||
{#if allowManualEntry}
|
||||
<div class="manual-section">
|
||||
{#if showManualEntry}
|
||||
<div class="manual-form">
|
||||
<div class="input-with-icon">
|
||||
<Envelope size={14} />
|
||||
<input
|
||||
type="email"
|
||||
bind:value={manualEmail}
|
||||
placeholder="E-Mail-Adresse *"
|
||||
class="manual-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-with-icon">
|
||||
<User size={14} />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={manualName}
|
||||
placeholder="Name (optional)"
|
||||
class="manual-input"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleAddManualEntry()}
|
||||
/>
|
||||
</div>
|
||||
<div class="manual-actions">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showManualEntry = false)}
|
||||
class="btn-cancel"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleAddManualEntry}
|
||||
disabled={!manualEmail.trim() || !isValidEmail(manualEmail)}
|
||||
class="btn-add"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button" onclick={() => (showManualEntry = true)} class="manual-trigger">
|
||||
<Envelope size={14} />
|
||||
<span>E-Mail manuell hinzufügen</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.contact-selector-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selected-contacts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
background: transparent;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.2);
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .add-button {
|
||||
color: #9ca3af;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.add-button:hover:not(:disabled) {
|
||||
color: #374151;
|
||||
border-color: rgba(0, 0, 0, 0.3);
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
:global(.dark) .add-button:hover:not(:disabled) {
|
||||
color: #e5e7eb;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.add-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
margin-top: 0.25rem;
|
||||
width: 100%;
|
||||
min-width: 320px;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 12px 28px -5px rgba(0, 0, 0, 0.2),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.dark) .dropdown {
|
||||
background: rgba(45, 45, 45, 1);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
box-shadow:
|
||||
0 12px 28px -5px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Search Section */
|
||||
.search-section {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:global(.dark) .search-section {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input-wrapper :global(.search-icon) {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:global(.dark) .search-input-wrapper :global(.search-icon) {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 0.75rem;
|
||||
outline: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .search-input {
|
||||
color: #f3f4f6;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #8b5cf6;
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Results List */
|
||||
.results-list {
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:global(.dark) .empty-state {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.result-item:hover,
|
||||
.result-item.highlighted {
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
}
|
||||
|
||||
:global(.dark) .result-item:hover,
|
||||
:global(.dark) .result-item.highlighted {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
|
||||
.result-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:global(.dark) .result-name {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.result-detail {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:global(.dark) .result-detail {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Manual Entry Section */
|
||||
.manual-section {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
:global(.dark) .manual-section {
|
||||
border-top-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.manual-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-with-icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-with-icon > :global(svg) {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:global(.dark) .input-with-icon > :global(svg) {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.manual-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 0.75rem;
|
||||
outline: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .manual-input {
|
||||
color: #f3f4f6;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.manual-input:focus {
|
||||
border-color: #8b5cf6;
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.manual-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.manual-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .btn-cancel {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .btn-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
background: #8b5cf6;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-add:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-add:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.manual-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
:global(.dark) .manual-trigger {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.manual-trigger:hover {
|
||||
color: #374151;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .manual-trigger:hover {
|
||||
color: #e5e7eb;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Unavailable State */
|
||||
.unavailable-state {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:global(.dark) .unavailable-state {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.unavailable-state > :global(svg) {
|
||||
margin: 0 auto 0.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.unavailable-state p {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.manual-link {
|
||||
font-size: 0.875rem;
|
||||
color: #8b5cf6;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.manual-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
4
packages/shared-ui/src/molecules/contacts/index.ts
Normal file
4
packages/shared-ui/src/molecules/contacts/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Contact selection and display components
|
||||
export { default as ContactAvatar } from './ContactAvatar.svelte';
|
||||
export { default as ContactBadge } from './ContactBadge.svelte';
|
||||
export { default as ContactSelector } from './ContactSelector.svelte';
|
||||
|
|
@ -39,6 +39,9 @@ export {
|
|||
// Feedback components
|
||||
export { EmptyState } from './feedback';
|
||||
|
||||
// Contact components
|
||||
export { ContactAvatar, ContactBadge, ContactSelector } from './contacts';
|
||||
|
||||
// Layout components
|
||||
export { default as ModalFooter } from './ModalFooter.svelte';
|
||||
export { default as DataCard } from './DataCard.svelte';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue