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:
Till JS 2026-04-02 21:21:07 +02:00
parent aa93c54391
commit eb97378438
3 changed files with 928 additions and 276 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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;