mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:41:09 +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
|
|
@ -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