mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 02:39:41 +02:00
feat(manacore/web): add page carousel to contacts module
Add ContactPage and ContactPagePicker components using the shared PageCarousel/PageShell system. Available pages: Alle Kontakte, Favoriten, Bald Geburtstag, Mit E-Mail, Mit Telefon, Mit Unternehmen, Mit Adresse, Kürzlich hinzugefügt. Default opens "Alle" + "Favoriten". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aa93c54391
commit
eb97378438
3 changed files with 928 additions and 276 deletions
|
|
@ -0,0 +1,439 @@
|
|||
<!--
|
||||
ContactPage — A single page in the contacts carousel.
|
||||
Shows a filtered/sorted contact list inside a PageShell.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { isToday, differenceInDays, startOfDay, setYear } from 'date-fns';
|
||||
import { dropTarget } from '@manacore/shared-ui/dnd';
|
||||
import { FavoriteButton } from '@manacore/shared-ui';
|
||||
import type { DragPayload, TagDragData } from '@manacore/shared-ui/dnd';
|
||||
import {
|
||||
Star,
|
||||
Users,
|
||||
Cake,
|
||||
Heart,
|
||||
Envelope,
|
||||
Phone,
|
||||
Briefcase,
|
||||
MapPin,
|
||||
Clock,
|
||||
} from '@manacore/shared-icons';
|
||||
import { PageShell } from '$lib/components/page-carousel';
|
||||
import type { Contact } from '../../types';
|
||||
import {
|
||||
getDisplayName,
|
||||
getInitials,
|
||||
searchContacts,
|
||||
sortContacts,
|
||||
groupByLetter,
|
||||
} from '../../queries';
|
||||
import { contactsStore } from '../../stores/contacts.svelte';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export type ContactPageId =
|
||||
| 'all'
|
||||
| 'favorites'
|
||||
| 'birthday-soon'
|
||||
| 'has-email'
|
||||
| 'has-phone'
|
||||
| 'with-company'
|
||||
| 'with-address'
|
||||
| 'recent';
|
||||
|
||||
interface Props {
|
||||
pageId: ContactPageId;
|
||||
allContacts: Contact[];
|
||||
widthPx: number;
|
||||
maximized?: boolean;
|
||||
searchQuery?: string;
|
||||
onClose: () => void;
|
||||
onMinimize?: () => void;
|
||||
onMaximize?: () => void;
|
||||
onResize?: (widthPx: number) => void;
|
||||
onOpenContact?: (contact: Contact) => void;
|
||||
onTagDrop?: (contact: Contact, payload: DragPayload) => void;
|
||||
tagMap?: Map<string, { id: string; name: string; color: string | null }>;
|
||||
}
|
||||
|
||||
let {
|
||||
pageId,
|
||||
allContacts,
|
||||
widthPx,
|
||||
maximized = false,
|
||||
searchQuery = '',
|
||||
onClose,
|
||||
onMinimize,
|
||||
onMaximize,
|
||||
onResize,
|
||||
onOpenContact,
|
||||
onTagDrop,
|
||||
tagMap,
|
||||
}: Props = $props();
|
||||
|
||||
const PAGE_META: Record<
|
||||
ContactPageId,
|
||||
{ title: string; color: string; icon: Component; filterFn: (c: Contact) => boolean }
|
||||
> = {
|
||||
all: {
|
||||
title: 'Alle Kontakte',
|
||||
color: '#3B82F6',
|
||||
icon: Users,
|
||||
filterFn: () => true,
|
||||
},
|
||||
favorites: {
|
||||
title: 'Favoriten',
|
||||
color: '#F59E0B',
|
||||
icon: Star,
|
||||
filterFn: (c) => c.isFavorite,
|
||||
},
|
||||
'birthday-soon': {
|
||||
title: 'Bald Geburtstag',
|
||||
color: '#EC4899',
|
||||
icon: Cake,
|
||||
filterFn: (c) => {
|
||||
if (!c.birthday) return false;
|
||||
const today = startOfDay(new Date());
|
||||
const bday = startOfDay(setYear(new Date(c.birthday), today.getFullYear()));
|
||||
const diff = differenceInDays(bday, today);
|
||||
// Show birthdays in the next 30 days (or today)
|
||||
return diff >= 0 && diff <= 30;
|
||||
},
|
||||
},
|
||||
'has-email': {
|
||||
title: 'Mit E-Mail',
|
||||
color: '#6366F1',
|
||||
icon: Envelope,
|
||||
filterFn: (c) => !!c.email,
|
||||
},
|
||||
'has-phone': {
|
||||
title: 'Mit Telefon',
|
||||
color: '#22C55E',
|
||||
icon: Phone,
|
||||
filterFn: (c) => !!c.phone || !!c.mobile,
|
||||
},
|
||||
'with-company': {
|
||||
title: 'Mit Unternehmen',
|
||||
color: '#8B5CF6',
|
||||
icon: Briefcase,
|
||||
filterFn: (c) => !!c.company,
|
||||
},
|
||||
'with-address': {
|
||||
title: 'Mit Adresse',
|
||||
color: '#F97316',
|
||||
icon: MapPin,
|
||||
filterFn: (c) => !!c.city || !!c.street,
|
||||
},
|
||||
recent: {
|
||||
title: 'Kürzlich hinzugefügt',
|
||||
color: '#6B7280',
|
||||
icon: Clock,
|
||||
filterFn: (c) => {
|
||||
const days = differenceInDays(new Date(), new Date(c.createdAt));
|
||||
return days <= 14;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let meta = $derived(PAGE_META[pageId]);
|
||||
|
||||
let filtered = $derived.by(() => {
|
||||
const active = allContacts.filter((c) => !c.isArchived);
|
||||
const byPage = active.filter(meta.filterFn);
|
||||
const searched = searchContacts(byPage, searchQuery);
|
||||
|
||||
// Birthday-soon: sort by upcoming birthday
|
||||
if (pageId === 'birthday-soon') {
|
||||
const today = startOfDay(new Date());
|
||||
return [...searched].sort((a, b) => {
|
||||
const aDay = startOfDay(setYear(new Date(a.birthday!), today.getFullYear()));
|
||||
const bDay = startOfDay(setYear(new Date(b.birthday!), today.getFullYear()));
|
||||
return differenceInDays(aDay, today) - differenceInDays(bDay, today);
|
||||
});
|
||||
}
|
||||
|
||||
// Recent: sort by newest first
|
||||
if (pageId === 'recent') {
|
||||
return [...searched].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
return sortContacts(searched, 'firstName');
|
||||
});
|
||||
|
||||
let groups = $derived(
|
||||
pageId === 'birthday-soon' || pageId === 'recent' ? null : groupByLetter(filtered, 'firstName')
|
||||
);
|
||||
let letters = $derived(groups ? Object.keys(groups).sort() : []);
|
||||
|
||||
function getContactTags(contact: Contact) {
|
||||
if (!tagMap) return [];
|
||||
return (contact.tagIds ?? [])
|
||||
.map((id) => tagMap.get(id))
|
||||
.filter((t): t is NonNullable<typeof t> => t != null);
|
||||
}
|
||||
|
||||
function tagNotAlreadyOnContact(contact: Contact) {
|
||||
return (payload: DragPayload) => {
|
||||
const tagData = payload.data as unknown as TagDragData;
|
||||
return !(contact.tagIds ?? []).includes(tagData.id);
|
||||
};
|
||||
}
|
||||
|
||||
function getBirthdayLabel(birthday: string): string {
|
||||
const today = startOfDay(new Date());
|
||||
const bday = startOfDay(setYear(new Date(birthday), today.getFullYear()));
|
||||
const diff = differenceInDays(bday, today);
|
||||
if (diff === 0) return 'Heute!';
|
||||
if (diff === 1) return 'Morgen';
|
||||
return `in ${diff} Tagen`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageShell
|
||||
{widthPx}
|
||||
{maximized}
|
||||
title={meta.title}
|
||||
color={meta.color}
|
||||
icon={meta.icon}
|
||||
{onClose}
|
||||
{onMinimize}
|
||||
{onMaximize}
|
||||
{onResize}
|
||||
>
|
||||
{#snippet badge()}
|
||||
<span class="contact-count">{filtered.length}</span>
|
||||
{/snippet}
|
||||
|
||||
<div class="page-content">
|
||||
{#if filtered.length === 0}
|
||||
<div class="empty-state">
|
||||
<meta.icon size={28} />
|
||||
<span>Keine Kontakte</span>
|
||||
</div>
|
||||
{:else if groups}
|
||||
<!-- Alphabetical grouping -->
|
||||
{#each letters as letter (letter)}
|
||||
<div class="letter-group">
|
||||
<div class="letter-header">{letter}</div>
|
||||
{#each groups[letter] as contact (contact.id)}
|
||||
{@render contactRow(contact)}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Flat list (birthday-soon, recent) -->
|
||||
{#each filtered as contact (contact.id)}
|
||||
{@render contactRow(contact)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
{#snippet contactRow(contact: Contact)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="contact-row"
|
||||
onclick={() => onOpenContact?.(contact)}
|
||||
use:dropTarget={{
|
||||
accepts: ['tag'],
|
||||
onDrop: (payload) => onTagDrop?.(contact, payload),
|
||||
canDrop: tagNotAlreadyOnContact(contact),
|
||||
}}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="avatar">
|
||||
{#if contact.photoUrl}
|
||||
<img src={contact.photoUrl} alt={getDisplayName(contact)} class="avatar-img" />
|
||||
{:else}
|
||||
{getInitials(contact)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="contact-info">
|
||||
<div class="contact-name-row">
|
||||
<span class="contact-name">{getDisplayName(contact)}</span>
|
||||
{#if contact.isFavorite}
|
||||
<Star weight="fill" size={12} class="favorite-star" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if contact.company || contact.jobTitle}
|
||||
<div class="contact-subtitle">
|
||||
{[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
|
||||
</div>
|
||||
{/if}
|
||||
{#if pageId === 'birthday-soon' && contact.birthday}
|
||||
<div class="birthday-badge">
|
||||
🎂 {getBirthdayLabel(contact.birthday)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if getContactTags(contact).length > 0}
|
||||
<div class="tag-row">
|
||||
{#each getContactTags(contact).slice(0, 3) as tag (tag.id)}
|
||||
<span
|
||||
class="tag-pill"
|
||||
style="background: color-mix(in srgb, {tag.color} 15%, transparent); color: {tag.color}"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Favorite toggle -->
|
||||
<div class="row-actions">
|
||||
<FavoriteButton
|
||||
active={contact.isFavorite}
|
||||
onclick={() => contactsStore.toggleFavorite(contact.id)}
|
||||
variant="star"
|
||||
size={14}
|
||||
activeColor="#f59e0b"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.contact-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
:global(.dark) .contact-count {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 200px;
|
||||
color: #9ca3af;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.letter-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.letter-header {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #9ca3af;
|
||||
padding: 0.25rem 0.5rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #fffef5;
|
||||
z-index: 1;
|
||||
}
|
||||
:global(.dark) .letter-header {
|
||||
background: #252220;
|
||||
}
|
||||
|
||||
.contact-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.contact-row:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
:global(.dark) .contact-row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
background: color-mix(in srgb, var(--color-primary, #8b5cf6) 12%, transparent);
|
||||
color: var(--color-primary, #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.contact-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.contact-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
:global(.dark) .contact-name {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
:global(.favorite-star) {
|
||||
color: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.contact-subtitle {
|
||||
font-size: 0.6875rem;
|
||||
color: #9ca3af;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.birthday-badge {
|
||||
font-size: 0.6875rem;
|
||||
color: #ec4899;
|
||||
font-weight: 500;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.tag-row {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.tag-pill {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 500;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.contact-row:hover .row-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import {
|
||||
Users,
|
||||
Star,
|
||||
Cake,
|
||||
Envelope,
|
||||
Phone,
|
||||
Briefcase,
|
||||
MapPin,
|
||||
Clock,
|
||||
X,
|
||||
} from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
onSelect: (pageId: string) => void;
|
||||
onClose: () => void;
|
||||
activePageIds?: string[];
|
||||
}
|
||||
|
||||
let { onSelect, onClose, activePageIds = [] }: Props = $props();
|
||||
|
||||
const PAGE_OPTIONS = [
|
||||
{
|
||||
id: 'all',
|
||||
title: 'Alle Kontakte',
|
||||
description: 'Vollständige Kontaktliste',
|
||||
icon: Users,
|
||||
color: '#3B82F6',
|
||||
},
|
||||
{
|
||||
id: 'favorites',
|
||||
title: 'Favoriten',
|
||||
description: 'Markierte Kontakte',
|
||||
icon: Star,
|
||||
color: '#F59E0B',
|
||||
},
|
||||
{
|
||||
id: 'birthday-soon',
|
||||
title: 'Bald Geburtstag',
|
||||
description: 'Geburtstage der nächsten 30 Tage',
|
||||
icon: Cake,
|
||||
color: '#EC4899',
|
||||
},
|
||||
{
|
||||
id: 'has-email',
|
||||
title: 'Mit E-Mail',
|
||||
description: 'Kontakte mit E-Mail-Adresse',
|
||||
icon: Envelope,
|
||||
color: '#6366F1',
|
||||
},
|
||||
{
|
||||
id: 'has-phone',
|
||||
title: 'Mit Telefon',
|
||||
description: 'Kontakte mit Telefonnummer',
|
||||
icon: Phone,
|
||||
color: '#22C55E',
|
||||
},
|
||||
{
|
||||
id: 'with-company',
|
||||
title: 'Mit Unternehmen',
|
||||
description: 'Geschäftliche Kontakte',
|
||||
icon: Briefcase,
|
||||
color: '#8B5CF6',
|
||||
},
|
||||
{
|
||||
id: 'with-address',
|
||||
title: 'Mit Adresse',
|
||||
description: 'Kontakte mit Anschrift',
|
||||
icon: MapPin,
|
||||
color: '#F97316',
|
||||
},
|
||||
{
|
||||
id: 'recent',
|
||||
title: 'Kürzlich hinzugefügt',
|
||||
description: 'Neue Kontakte der letzten 14 Tage',
|
||||
icon: Clock,
|
||||
color: '#6B7280',
|
||||
},
|
||||
];
|
||||
|
||||
let availableOptions = $derived(PAGE_OPTIONS.filter((opt) => !activePageIds.includes(opt.id)));
|
||||
</script>
|
||||
|
||||
<div class="page-picker">
|
||||
<div class="picker-header">
|
||||
<h3 class="picker-title">Neue Seite</h3>
|
||||
<button class="close-btn" onclick={onClose} title={$_('common.close')}><X size={16} /></button>
|
||||
</div>
|
||||
<div class="picker-list">
|
||||
{#each availableOptions as option, i (option.id)}
|
||||
{#if i > 0}<div class="divider"></div>{/if}
|
||||
<button class="page-option" onclick={() => onSelect(option.id)}>
|
||||
<div class="option-icon" style="color: {option.color}"><option.icon size={20} /></div>
|
||||
<div class="option-text">
|
||||
<span class="option-title">{option.title}</span>
|
||||
<span class="option-desc">{option.description}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#if availableOptions.length === 0}
|
||||
<div class="empty-state"><p>Alle Seiten sind bereits geöffnet</p></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-picker {
|
||||
flex: 0 0 auto;
|
||||
width: min(320px, 85vw);
|
||||
min-height: 60vh;
|
||||
background: #fffef5;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 0.25s ease-out;
|
||||
}
|
||||
:global(.dark) .page-picker {
|
||||
background-color: #252220;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.picker-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
}
|
||||
:global(.dark) .picker-title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.close-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.picker-list {
|
||||
flex: 1;
|
||||
padding: 0 0.5rem 0.75rem;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
:global(.dark) .divider {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.page-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
transition: background 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
.page-option:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
:global(.dark) .page-option:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.option-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
.option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.option-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .option-title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.option-desc {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .option-desc {
|
||||
color: #6b7280;
|
||||
}
|
||||
.empty-state {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-state p {
|
||||
font-size: 0.8125rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,39 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import type { Observable } from 'dexie';
|
||||
import { dropTarget } from '@manacore/shared-ui/dnd';
|
||||
import { FavoriteButton } from '@manacore/shared-ui';
|
||||
import type { DragPayload, TagDragData } from '@manacore/shared-ui/dnd';
|
||||
import { useAllTags } from '$lib/stores/tags.svelte';
|
||||
import {
|
||||
type Contact,
|
||||
contactsFilterStore,
|
||||
contactsStore,
|
||||
contactModalStore,
|
||||
searchContacts,
|
||||
filterActive,
|
||||
filterFavorites,
|
||||
sortContacts,
|
||||
applyContactFilter,
|
||||
groupByLetter,
|
||||
getDisplayName,
|
||||
getInitials,
|
||||
contactsFilterStore,
|
||||
} from '$lib/modules/contacts';
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
Plus,
|
||||
Star,
|
||||
Archive,
|
||||
Trash,
|
||||
PencilSimple,
|
||||
Funnel,
|
||||
Users,
|
||||
User,
|
||||
Envelope,
|
||||
Briefcase,
|
||||
MapPin,
|
||||
X,
|
||||
} from '@manacore/shared-icons';
|
||||
import { Plus, MagnifyingGlass, X } from '@manacore/shared-icons';
|
||||
import { PageCarousel, type CarouselPage } from '$lib/components/page-carousel';
|
||||
import ContactPage from '$lib/modules/contacts/components/pages/ContactPage.svelte';
|
||||
import type { ContactPageId } from '$lib/modules/contacts/components/pages/ContactPage.svelte';
|
||||
import ContactPagePicker from '$lib/modules/contacts/components/pages/ContactPagePicker.svelte';
|
||||
|
||||
// Get contacts from layout context
|
||||
const allContacts$: Observable<Contact[]> = getContext('contacts');
|
||||
|
|
@ -47,46 +28,19 @@
|
|||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
// Filtered & sorted contacts
|
||||
let activeContacts = $derived(filterActive(allContacts));
|
||||
let filtered = $derived(applyContactFilter(activeContacts, contactsFilterStore.contactFilter));
|
||||
let searched = $derived(searchContacts(filtered, contactsFilterStore.searchQuery));
|
||||
let sorted = $derived(sortContacts(searched, contactsFilterStore.sortField));
|
||||
|
||||
// Stats
|
||||
let totalCount = $derived(activeContacts.length);
|
||||
let favoriteCount = $derived(filterFavorites(activeContacts).length);
|
||||
|
||||
// Alphabet grouping
|
||||
let groups = $derived(groupByLetter(sorted, contactsFilterStore.sortField));
|
||||
let letters = $derived(Object.keys(groups).sort());
|
||||
|
||||
// ── DnD: tag support ────────────────────────────────────
|
||||
// Tags for DnD
|
||||
const globalTags = useAllTags();
|
||||
const tagMap = $derived(new Map((globalTags.value ?? []).map((t) => [t.id, t])));
|
||||
|
||||
function getContactTags(contact: Contact) {
|
||||
return (contact.tagIds ?? [])
|
||||
.map((id) => tagMap.get(id))
|
||||
.filter((t): t is NonNullable<typeof t> => t != null);
|
||||
}
|
||||
|
||||
function handleTagDrop(contact: Contact, payload: DragPayload) {
|
||||
const tagData = payload.data as TagDragData;
|
||||
const tagData = payload.data as unknown as TagDragData;
|
||||
const current = contact.tagIds ?? [];
|
||||
if (!current.includes(tagData.id)) {
|
||||
contactsStore.updateTagIds(contact.id, [...current, tagData.id]);
|
||||
}
|
||||
}
|
||||
|
||||
function tagNotAlreadyOnContact(contact: Contact) {
|
||||
return (payload: DragPayload) => {
|
||||
const tagData = payload.data as TagDragData;
|
||||
return !(contact.tagIds ?? []).includes(tagData.id);
|
||||
};
|
||||
}
|
||||
|
||||
// Register passive handler for contact→tag direction
|
||||
// Register passive handler for tag→contact direction
|
||||
const tagDropCtx = getContext<{
|
||||
set: (handler: (tagId: string, payload: DragPayload) => void) => void;
|
||||
clear: () => void;
|
||||
|
|
@ -107,216 +61,163 @@
|
|||
return () => tagDropCtx?.clear();
|
||||
});
|
||||
|
||||
// Handlers
|
||||
function handleToggleFavorite(e: MouseEvent, id: string) {
|
||||
e.stopPropagation();
|
||||
contactsStore.toggleFavorite(id);
|
||||
// ── Page state ──────────────────────────────────────────
|
||||
const DEFAULT_WIDTH = 420;
|
||||
let showPicker = $state(false);
|
||||
let openPages = $state<
|
||||
{ id: string; minimized: boolean; maximized?: boolean; widthPx?: number }[]
|
||||
>([
|
||||
{ id: 'all', minimized: false },
|
||||
{ id: 'favorites', minimized: false },
|
||||
]);
|
||||
|
||||
const PAGE_META: Record<string, { title: string; color: string }> = {
|
||||
all: { title: 'Alle Kontakte', color: '#3B82F6' },
|
||||
favorites: { title: 'Favoriten', color: '#F59E0B' },
|
||||
'birthday-soon': { title: 'Bald Geburtstag', color: '#EC4899' },
|
||||
'has-email': { title: 'Mit E-Mail', color: '#6366F1' },
|
||||
'has-phone': { title: 'Mit Telefon', color: '#22C55E' },
|
||||
'with-company': { title: 'Mit Unternehmen', color: '#8B5CF6' },
|
||||
'with-address': { title: 'Mit Adresse', color: '#F97316' },
|
||||
recent: { title: 'Kürzlich hinzugefügt', color: '#6B7280' },
|
||||
};
|
||||
|
||||
let carouselPages = $derived<CarouselPage[]>(
|
||||
openPages.map((p) => {
|
||||
const meta = PAGE_META[p.id];
|
||||
return {
|
||||
id: p.id,
|
||||
minimized: p.minimized,
|
||||
maximized: p.maximized,
|
||||
widthPx: p.widthPx ?? DEFAULT_WIDTH,
|
||||
title: meta?.title ?? p.id,
|
||||
color: meta?.color ?? '#6B7280',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
function handleAddPage(pageId: string) {
|
||||
if (!openPages.some((p) => p.id === pageId)) {
|
||||
openPages = [...openPages, { id: pageId, minimized: false }];
|
||||
} else {
|
||||
openPages = openPages.map((p) => (p.id === pageId ? { ...p, minimized: false } : p));
|
||||
}
|
||||
showPicker = false;
|
||||
}
|
||||
|
||||
function handleArchive(e: MouseEvent, id: string) {
|
||||
e.stopPropagation();
|
||||
contactsStore.toggleArchive(id);
|
||||
function handleRemovePage(id: string) {
|
||||
openPages = openPages.filter((p) => p.id !== id);
|
||||
}
|
||||
|
||||
function handleDelete(e: MouseEvent, contact: Contact) {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`"${getDisplayName(contact)}" endgueltig loeschen?`)) return;
|
||||
contactsStore.deleteContact(contact.id);
|
||||
function handleMinimizePage(id: string) {
|
||||
openPages = openPages.map((p) => (p.id === id ? { ...p, minimized: true } : p));
|
||||
}
|
||||
|
||||
let showFilters = $state(false);
|
||||
function handleRestorePage(id: string) {
|
||||
openPages = openPages.map((p) => (p.id === id ? { ...p, minimized: false } : p));
|
||||
}
|
||||
|
||||
function handleMaximizePage(id: string) {
|
||||
openPages = openPages.map((p) =>
|
||||
p.id === id ? { ...p, maximized: !p.maximized, minimized: false } : p
|
||||
);
|
||||
}
|
||||
|
||||
function handleResize(id: string, widthPx: number) {
|
||||
openPages = openPages.map((p) => (p.id === id ? { ...p, widthPx } : p));
|
||||
}
|
||||
|
||||
function handleReorder(fromId: string, toId: string) {
|
||||
const fromIdx = openPages.findIndex((p) => p.id === fromId);
|
||||
const toIdx = openPages.findIndex((p) => p.id === toId);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
const pages = [...openPages];
|
||||
const [moved] = pages.splice(fromIdx, 1);
|
||||
pages.splice(toIdx, 0, moved);
|
||||
openPages = pages;
|
||||
}
|
||||
|
||||
function navigateToContact(contact: Contact) {
|
||||
window.location.href = `/contacts/${contact.id}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Kontakte - ManaCore</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="contacts-board">
|
||||
<!-- Header -->
|
||||
<header class="mb-6 flex items-center justify-between">
|
||||
<header class="contacts-header">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">Kontakte</h1>
|
||||
<p class="text-muted-foreground mt-1 text-sm">
|
||||
{totalCount} Kontakte{favoriteCount > 0 ? ` · ${favoriteCount} Favoriten` : ''}
|
||||
<h1 class="contacts-title">Kontakte</h1>
|
||||
<p class="contacts-stats">
|
||||
{allContacts.filter((c) => !c.isArchived).length} Kontakte
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => contactModalStore.open()}
|
||||
class="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Neu
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<!-- Search -->
|
||||
<div class="search-bar">
|
||||
<MagnifyingGlass size={16} class="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={contactsFilterStore.searchQuery}
|
||||
oninput={(e) => contactsFilterStore.setSearchQuery(e.currentTarget.value)}
|
||||
class="search-input"
|
||||
/>
|
||||
{#if contactsFilterStore.searchQuery}
|
||||
<button class="search-clear" onclick={() => contactsFilterStore.setSearchQuery('')}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="new-btn" onclick={() => contactModalStore.open()}>
|
||||
<Plus size={16} />
|
||||
Neu
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Search & Filter Bar -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<MagnifyingGlass
|
||||
size={18}
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
<!-- Page carousel -->
|
||||
<PageCarousel
|
||||
pages={carouselPages}
|
||||
defaultWidth={DEFAULT_WIDTH}
|
||||
{showPicker}
|
||||
onReorder={handleReorder}
|
||||
onRestore={handleRestorePage}
|
||||
onMaximize={handleMaximizePage}
|
||||
onRemove={handleRemovePage}
|
||||
onTogglePicker={() => (showPicker = !showPicker)}
|
||||
addLabel="Seite hinzufügen"
|
||||
>
|
||||
{#snippet page(p)}
|
||||
<ContactPage
|
||||
pageId={p.id as ContactPageId}
|
||||
{allContacts}
|
||||
widthPx={p.widthPx}
|
||||
maximized={p.maximized}
|
||||
searchQuery={contactsFilterStore.searchQuery}
|
||||
onClose={() => handleRemovePage(p.id)}
|
||||
onMinimize={() => handleMinimizePage(p.id)}
|
||||
onMaximize={() => handleMaximizePage(p.id)}
|
||||
onResize={(w) => handleResize(p.id, w)}
|
||||
onOpenContact={navigateToContact}
|
||||
onTagDrop={handleTagDrop}
|
||||
{tagMap}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kontakte suchen..."
|
||||
value={contactsFilterStore.searchQuery}
|
||||
oninput={(e) => contactsFilterStore.setSearchQuery(e.currentTarget.value)}
|
||||
class="w-full rounded-lg border border-border bg-card py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
{/snippet}
|
||||
{#snippet picker()}
|
||||
<ContactPagePicker
|
||||
onSelect={handleAddPage}
|
||||
onClose={() => (showPicker = false)}
|
||||
activePageIds={openPages.map((p) => p.id)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
class="flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-2 text-sm transition-colors hover:bg-muted"
|
||||
class:border-primary={contactsFilterStore.contactFilter !== 'all'}
|
||||
class:text-primary={contactsFilterStore.contactFilter !== 'all'}
|
||||
>
|
||||
<Funnel size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Options -->
|
||||
{#if showFilters}
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
{#each [{ value: 'all', label: 'Alle' }, { value: 'favorites', label: 'Favoriten' }, { value: 'hasEmail', label: 'Mit E-Mail' }, { value: 'hasPhone', label: 'Mit Telefon' }, { value: 'incomplete', label: 'Unvollstaendig' }] as filter}
|
||||
<button
|
||||
onclick={() => contactsFilterStore.setContactFilter(filter.value)}
|
||||
class="rounded-full border px-3 py-1 text-xs font-medium transition-colors
|
||||
{contactsFilterStore.contactFilter === filter.value
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:border-primary/50'}"
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Contact List -->
|
||||
{#if sorted.length === 0}
|
||||
<div class="flex flex-col items-center py-12 text-center">
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Users size={32} class="text-muted-foreground" />
|
||||
</div>
|
||||
{#if contactsFilterStore.searchQuery}
|
||||
<h2 class="mb-1 text-lg font-semibold text-foreground">Keine Ergebnisse</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Keine Kontakte gefunden fuer "{contactsFilterStore.searchQuery}"
|
||||
</p>
|
||||
{:else}
|
||||
<h2 class="mb-1 text-lg font-semibold text-foreground">Noch keine Kontakte</h2>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Erstelle deinen ersten Kontakt oder importiere bestehende.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => contactModalStore.open()}
|
||||
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
||||
>
|
||||
Kontakt erstellen
|
||||
</button>
|
||||
<a
|
||||
href="/contacts/import"
|
||||
class="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
|
||||
>
|
||||
Importieren
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Alphabet sections -->
|
||||
{#each letters as letter (letter)}
|
||||
<div class="mb-4">
|
||||
<div
|
||||
class="sticky top-0 z-10 mb-1 bg-background/90 px-1 py-1 text-xs font-bold uppercase tracking-wider text-muted-foreground backdrop-blur-sm"
|
||||
>
|
||||
{letter}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
{#each groups[letter] as contact (contact.id)}
|
||||
<a
|
||||
href="/contacts/{contact.id}"
|
||||
class="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 transition-colors hover:border-border hover:bg-card group"
|
||||
use:dropTarget={{
|
||||
accepts: ['tag'],
|
||||
onDrop: (payload) => handleTagDrop(contact, payload),
|
||||
canDrop: tagNotAlreadyOnContact(contact),
|
||||
}}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
|
||||
>
|
||||
{#if contact.photoUrl}
|
||||
<img
|
||||
src={contact.photoUrl}
|
||||
alt={getDisplayName(contact)}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
{getInitials(contact)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground truncate">
|
||||
{getDisplayName(contact)}
|
||||
</span>
|
||||
{#if contact.isFavorite}
|
||||
<Star weight="fill" size={14} class="flex-shrink-0 text-amber-500" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if contact.company || contact.jobTitle}
|
||||
<div class="truncate text-xs text-muted-foreground">
|
||||
{[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
|
||||
</div>
|
||||
{/if}
|
||||
{#if getContactTags(contact).length > 0}
|
||||
<div class="mt-0.5 flex gap-1">
|
||||
{#each getContactTags(contact).slice(0, 3) as tag (tag.id)}
|
||||
<span
|
||||
class="inline-flex rounded-full px-1.5 py-0.5 text-[0.625rem] font-medium"
|
||||
style="background: color-mix(in srgb, {tag.color} 15%, transparent); color: {tag.color}"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions (visible on hover) -->
|
||||
<div class="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<FavoriteButton
|
||||
active={contact.isFavorite}
|
||||
onclick={() => contactsStore.toggleFavorite(contact.id)}
|
||||
variant="star"
|
||||
size={14}
|
||||
activeColor="#f59e0b"
|
||||
/>
|
||||
<button
|
||||
onclick={(e) => handleArchive(e, contact.id)}
|
||||
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted"
|
||||
title="Archivieren"
|
||||
>
|
||||
<Archive size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<p class="mt-4 text-center text-xs text-muted-foreground">
|
||||
{sorted.length} Kontakt{sorted.length !== 1 ? 'e' : ''}
|
||||
</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageCarousel>
|
||||
</div>
|
||||
|
||||
<!-- New/Edit Contact Modal -->
|
||||
<!-- New/Edit Contact Modal (preserved from original) -->
|
||||
{#if contactModalStore.isOpen}
|
||||
{@const isEditing = !!contactModalStore.editContactId}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
|
|
@ -329,7 +230,6 @@
|
|||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-card px-5 py-3"
|
||||
>
|
||||
|
|
@ -373,10 +273,8 @@
|
|||
}}
|
||||
class="space-y-0"
|
||||
>
|
||||
<!-- Name Section -->
|
||||
<div class="contact-section">
|
||||
<div class="section-icon-row">
|
||||
<User size={18} class="text-muted-foreground" />
|
||||
<span class="section-label">Name</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
|
|
@ -397,12 +295,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Section -->
|
||||
<div class="contact-section">
|
||||
<div class="section-icon-row">
|
||||
<Envelope size={18} class="text-muted-foreground" />
|
||||
<span class="section-label">Kontakt</span>
|
||||
</div>
|
||||
<div class="section-icon-row"><span class="section-label">Kontakt</span></div>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
|
|
@ -422,12 +316,8 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Work Section -->
|
||||
<div class="contact-section">
|
||||
<div class="section-icon-row">
|
||||
<Briefcase size={18} class="text-muted-foreground" />
|
||||
<span class="section-label">Arbeit</span>
|
||||
</div>
|
||||
<div class="section-icon-row"><span class="section-label">Arbeit</span></div>
|
||||
<input
|
||||
name="company"
|
||||
type="text"
|
||||
|
|
@ -439,12 +329,8 @@
|
|||
<input name="website" type="url" placeholder="Website" class="contact-input" />
|
||||
</div>
|
||||
|
||||
<!-- Address Section -->
|
||||
<div class="contact-section">
|
||||
<div class="section-icon-row">
|
||||
<MapPin size={18} class="text-muted-foreground" />
|
||||
<span class="section-label">Adresse</span>
|
||||
</div>
|
||||
<div class="section-icon-row"><span class="section-label">Adresse</span></div>
|
||||
<input
|
||||
name="street"
|
||||
type="text"
|
||||
|
|
@ -458,21 +344,13 @@
|
|||
<input name="country" type="text" placeholder="Land" class="contact-input" />
|
||||
</div>
|
||||
|
||||
<!-- Birthday -->
|
||||
<div class="contact-section">
|
||||
<div class="section-icon-row">
|
||||
<span class="text-muted-foreground text-sm">🎂</span>
|
||||
<span class="section-label">Geburtstag</span>
|
||||
</div>
|
||||
<div class="section-icon-row"><span class="section-label">🎂 Geburtstag</span></div>
|
||||
<input name="birthday" type="date" class="contact-input" />
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<div class="contact-section">
|
||||
<div class="section-icon-row">
|
||||
<PencilSimple size={18} class="text-muted-foreground" />
|
||||
<span class="section-label">Notizen</span>
|
||||
</div>
|
||||
<div class="section-icon-row"><span class="section-label">Notizen</span></div>
|
||||
<textarea
|
||||
name="notes"
|
||||
rows="3"
|
||||
|
|
@ -481,11 +359,9 @@
|
|||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Social Media (collapsed by default) -->
|
||||
<details class="contact-section">
|
||||
<summary class="section-icon-row cursor-pointer select-none">
|
||||
<span class="text-muted-foreground text-sm">🔗</span>
|
||||
<span class="section-label">Social Media</span>
|
||||
<span class="section-label">🔗 Social Media</span>
|
||||
</summary>
|
||||
<div class="mt-2 space-y-2">
|
||||
<input name="linkedin" type="url" placeholder="LinkedIn URL" class="contact-input" />
|
||||
|
|
@ -495,7 +371,6 @@
|
|||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -517,6 +392,101 @@
|
|||
{/if}
|
||||
|
||||
<style>
|
||||
.contacts-board {
|
||||
min-height: calc(100vh - 140px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.contacts-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.contacts-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.contacts-stats {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
:global(.search-icon) {
|
||||
position: absolute;
|
||||
left: 0.625rem;
|
||||
color: var(--color-muted-foreground);
|
||||
pointer-events: none;
|
||||
}
|
||||
.search-input {
|
||||
width: 180px;
|
||||
padding: 0.375rem 0.5rem 0.375rem 2rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.search-input:focus {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground) / 0.5);
|
||||
}
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: 0.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.search-clear:hover {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.new-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.new-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Contact Modal Form Styles */
|
||||
.contact-section {
|
||||
padding: 0.75rem 1.25rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue