feat(ui): add shared ExpandableToolbar and unify toolbar dropdowns

- Create ExpandableToolbar shared component in shared-ui
- Migrate CalendarToolbar to use shared component (253 → 90 LOC)
- Migrate ContactsToolbar to use shared component (177 → 31 LOC)
- Add portal pattern to FilterBar dropdown for proper positioning
- Unify FilterBar dropdown styling with ContextMenu design
- Add fly transition with offset for smooth dropdown appearance
- Add ContactsToolbarContent for toolbar content separation
- Add filter store for contacts filter state management
- Add NewContactModal component
- Various contacts view improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-14 16:14:30 +01:00
parent 9eb3f42483
commit 863f296733
23 changed files with 2347 additions and 787 deletions

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { ExpandableToolbar } from '@manacore/shared-ui';
import CalendarToolbarContent from './CalendarToolbarContent.svelte';
interface Props {
@ -11,7 +11,7 @@
let {
isSidebarMode = false,
isCollapsed = true, // Default to collapsed
isCollapsed = true,
onModeChange,
onCollapsedChange,
}: Props = $props();
@ -19,210 +19,48 @@
function toggleSidebarMode() {
onModeChange?.(!isSidebarMode);
}
function toggleToolbar() {
onCollapsedChange?.(!isCollapsed);
}
</script>
<!-- FAB Button - positioned next to InputBar -->
<div class="fab-container" class:sidebar-mode={isSidebarMode} class:expanded={!isCollapsed}>
<button
onclick={toggleToolbar}
class="toolbar-fab glass-pill"
class:active={!isCollapsed}
title={isCollapsed ? 'Kalender-Optionen' : 'Schließen'}
>
<svg class="fab-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if isCollapsed}
<!-- Settings/sliders icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
/>
{:else}
<!-- Chevron down icon -->
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
{/if}
</svg>
</button>
</div>
<ExpandableToolbar
{isCollapsed}
{onCollapsedChange}
{isSidebarMode}
collapsedTitle="Kalender-Optionen"
expandedTitle="Schließen"
>
<CalendarToolbarContent />
<!-- Expanded Toolbar Panel - below InputBar, pushes content up -->
{#if !isCollapsed}
<div
class="toolbar-bar glass-panel"
class:sidebar-mode={isSidebarMode}
transition:slide={{ duration: 200 }}
>
<div class="toolbar-content">
<CalendarToolbarContent />
<div class="toolbar-divider"></div>
<!-- Layout Control -->
<button
onclick={toggleSidebarMode}
class="layout-btn"
title={isSidebarMode ? 'Zur Bottom-Navigation' : 'Zur Sidebar-Navigation'}
>
<svg class="layout-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if isSidebarMode}
<!-- Bottom bar layout icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 3h18v9H3V3zm0 12h18v6H3v-6z"
/>
{:else}
<!-- Sidebar layout icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 3h7v18H3V3zm9 0h9v18h-9V3z"
/>
{/if}
</svg>
</button>
</div>
</div>
{/if}
{#snippet rightActions()}
<button
onclick={toggleSidebarMode}
class="layout-btn"
title={isSidebarMode ? 'Zur Bottom-Navigation' : 'Zur Sidebar-Navigation'}
>
<svg class="layout-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if isSidebarMode}
<!-- Bottom bar layout icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 3h18v9H3V3zm0 12h18v6H3v-6z"
/>
{:else}
<!-- Sidebar layout icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 3h7v18H3V3zm9 0h9v18h-9V3z"
/>
{/if}
</svg>
</button>
{/snippet}
</ExpandableToolbar>
<style>
/* FAB Container - positioned next to InputBar (aligned with input-container) */
.fab-container {
position: fixed;
bottom: calc(
70px + 9px + env(safe-area-inset-bottom, 0px)
); /* 70px offset + 9px to align with input-container */
right: calc(50% - 350px - 70px); /* Right of InputBar (max-width 700px / 2 + gap) */
z-index: 91; /* Above InputBar (90) */
pointer-events: none;
transition: bottom 0.2s ease;
}
/* When expanded, move FAB up with InputBar */
.fab-container.expanded {
bottom: calc(140px + 9px + env(safe-area-inset-bottom, 0px));
}
.fab-container.sidebar-mode {
bottom: calc(0px + 9px + env(safe-area-inset-bottom, 0px));
}
.fab-container.sidebar-mode.expanded {
bottom: calc(60px + 9px + env(safe-area-inset-bottom, 0px));
}
/* Responsive positioning */
@media (max-width: 900px) {
.fab-container {
right: 1rem;
}
}
/* Toolbar Bar - full width below InputBar */
.toolbar-bar {
position: fixed;
bottom: calc(70px + env(safe-area-inset-bottom, 0px));
left: 0;
right: 0;
z-index: 89; /* Below InputBar (90) */
display: flex;
justify-content: center;
padding: 0.5rem 1rem;
}
.toolbar-bar.sidebar-mode {
bottom: calc(0px + env(safe-area-inset-bottom, 0px));
}
.toolbar-content {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: hsl(var(--color-surface) / 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid hsl(var(--color-border));
box-shadow: 0 -2px 16px hsl(var(--color-foreground) / 0.08);
border-radius: 1rem;
white-space: nowrap;
max-width: calc(100vw - 2rem);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.toolbar-content::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
/* Glass styling */
.glass-pill {
background: hsl(var(--color-surface) / 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-border));
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
border-radius: 9999px;
}
.glass-panel {
background: transparent;
}
/* FAB Button - same height as InputBar (54px) */
.toolbar-fab {
display: flex;
align-items: center;
justify-content: center;
width: 54px;
height: 54px;
cursor: pointer;
border: none;
transition: all 0.2s ease;
pointer-events: auto;
}
.toolbar-fab:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--color-foreground) / 0.15);
}
.toolbar-fab.active {
background: hsl(var(--color-muted));
}
.toolbar-fab.active .fab-icon {
color: hsl(var(--color-primary));
}
.fab-icon {
width: 1.5rem;
height: 1.5rem;
color: hsl(var(--color-muted-foreground));
transition: color 0.2s ease;
}
.toolbar-fab:hover .fab-icon {
color: hsl(var(--color-foreground));
}
.toolbar-divider {
width: 1px;
height: 1.5rem;
background: hsl(var(--color-border));
margin: 0 0.25rem;
}
/* Layout toggle button */
/* Layout toggle button - app-specific style */
.layout-btn {
display: flex;
align-items: center;

View file

@ -107,6 +107,72 @@ class CreateContactDto {
@IsOptional()
notes?: string;
// Social Media
@IsString()
@IsOptional()
@MaxLength(255)
linkedin?: string;
@IsString()
@IsOptional()
@MaxLength(100)
twitter?: string;
@IsString()
@IsOptional()
@MaxLength(255)
facebook?: string;
@IsString()
@IsOptional()
@MaxLength(100)
instagram?: string;
@IsString()
@IsOptional()
@MaxLength(255)
xing?: string;
@IsString()
@IsOptional()
@MaxLength(100)
github?: string;
@IsString()
@IsOptional()
@MaxLength(255)
youtube?: string;
@IsString()
@IsOptional()
@MaxLength(100)
tiktok?: string;
@IsString()
@IsOptional()
@MaxLength(100)
telegram?: string;
@IsString()
@IsOptional()
@MaxLength(50)
whatsapp?: string;
@IsString()
@IsOptional()
@MaxLength(50)
signal?: string;
@IsString()
@IsOptional()
@MaxLength(100)
discord?: string;
@IsString()
@IsOptional()
@MaxLength(100)
bluesky?: string;
@IsUUID()
@IsOptional()
organizationId?: string;

View file

@ -32,6 +32,21 @@ export const contacts = pgTable('contacts', {
notes: text('notes'),
photoUrl: varchar('photo_url', { length: 500 }),
// Social Media
linkedin: varchar('linkedin', { length: 255 }),
twitter: varchar('twitter', { length: 100 }),
facebook: varchar('facebook', { length: 255 }),
instagram: varchar('instagram', { length: 100 }),
xing: varchar('xing', { length: 255 }),
github: varchar('github', { length: 100 }),
youtube: varchar('youtube', { length: 255 }),
tiktok: varchar('tiktok', { length: 100 }),
telegram: varchar('telegram', { length: 100 }),
whatsapp: varchar('whatsapp', { length: 50 }),
signal: varchar('signal', { length: 50 }),
discord: varchar('discord', { length: 100 }),
bluesky: varchar('bluesky', { length: 100 }),
// Flags
isFavorite: boolean('is_favorite').default(false),
isArchived: boolean('is_archived').default(false),

View file

@ -49,6 +49,20 @@ export interface Contact {
birthday?: string | null;
notes?: string | null;
photoUrl?: string | null;
// Social Media
linkedin?: string | null;
twitter?: string | null;
facebook?: string | null;
instagram?: string | null;
xing?: string | null;
github?: string | null;
youtube?: string | null;
tiktok?: string | null;
telegram?: string | null;
whatsapp?: string | null;
signal?: string | null;
discord?: string | null;
bluesky?: string | null;
isFavorite: boolean;
isArchived: boolean;
organizationId?: string | null;

View file

@ -36,6 +36,22 @@
let country = $state('');
let notes = $state('');
// Social Media
let linkedin = $state('');
let twitter = $state('');
let facebook = $state('');
let instagram = $state('');
let xing = $state('');
let github = $state('');
let youtube = $state('');
let tiktok = $state('');
let telegram = $state('');
let whatsapp = $state('');
let signal = $state('');
let discord = $state('');
let bluesky = $state('');
let socialSectionOpen = $state(false);
const initials = $derived(() => {
if (!contact) return '?';
const f = contact.firstName?.[0] || '';

View file

@ -3,9 +3,8 @@
import { _ } from 'svelte-i18n';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import { goto } from '$app/navigation';
import type { ContactFilter, BirthdayFilter } from '$lib/components/FilterBar.svelte';
import ContactsToolbar, { type SortField } from '$lib/components/ContactsToolbar.svelte';
import ContactListView from '$lib/components/views/ContactListView.svelte';
import ContactGridView from '$lib/components/views/ContactGridView.svelte';
import ContactAlphabetView from '$lib/components/views/ContactAlphabetView.svelte';
@ -13,21 +12,10 @@
import { batchApi } from '$lib/api/batch';
import { toasts } from '$lib/stores/toast';
let searchQuery = $state('');
let sortField = $state<SortField>('lastName');
let searchTimeout: ReturnType<typeof setTimeout>;
// Infinite scroll
let scrollContainer: HTMLDivElement;
let intersectionObserver: IntersectionObserver | null = null;
let loadMoreTrigger: HTMLDivElement;
// Filter state
let selectedTagId = $state<string | null>(null);
let contactFilter = $state<ContactFilter>('all');
let birthdayFilter = $state<BirthdayFilter>('all');
let selectedCompany = $state<string | null>(null);
// Batch selection state
let selectionMode = $state(false);
let selectedIds = $state<Set<string>>(new Set());
@ -73,11 +61,31 @@
return !contact.phone && !contact.mobile && !contact.email;
}
// Filtered and sorted contacts
// Filtered and sorted contacts (using filter store)
let filteredContacts = $derived.by(() => {
let result = [...contactsStore.contacts];
// Apply contact filter
// Apply search filter from InputBar
const searchQuery = contactsFilterStore.searchQuery?.toLowerCase().trim();
if (searchQuery) {
result = result.filter((c) => {
const searchFields = [
c.firstName,
c.lastName,
c.displayName,
c.nickname,
c.company,
c.email,
c.phone,
c.mobile,
c.city,
];
return searchFields.some((field) => field?.toLowerCase().includes(searchQuery));
});
}
// Apply contact filter from store
const contactFilter = contactsFilterStore.contactFilter;
if (contactFilter === 'favorites') {
result = result.filter((c) => c.isFavorite);
} else if (contactFilter === 'hasPhone') {
@ -88,7 +96,8 @@
result = result.filter((c) => isContactIncomplete(c));
}
// Apply birthday filter
// Apply birthday filter from store
const birthdayFilter = contactsFilterStore.birthdayFilter;
if (birthdayFilter === 'today') {
result = result.filter((c) => isBirthdayToday(c.birthday));
} else if (birthdayFilter === 'thisWeek') {
@ -97,7 +106,8 @@
result = result.filter((c) => isBirthdayThisMonth(c.birthday));
}
// Apply company filter
// Apply company filter from store
const selectedCompany = contactsFilterStore.selectedCompany;
if (selectedCompany) {
result = result.filter((c) => c.company === selectedCompany);
}
@ -105,8 +115,9 @@
return result;
});
// Sorted contacts based on selected sort field
// Sorted contacts based on selected sort field from store
let sortedContacts = $derived.by(() => {
const sortField = contactsFilterStore.sortField;
return [...filteredContacts].sort((a, b) => {
const aValue =
(sortField === 'firstName'
@ -122,14 +133,6 @@
});
});
function handleSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
contactsStore.setSearch(searchQuery);
contactsStore.loadContacts();
}, 300);
}
async function handleToggleFavorite(e: MouseEvent, id: string) {
e.stopPropagation();
await contactsStore.toggleFavorite(id);
@ -266,6 +269,21 @@
intersectionObserver.observe(loadMoreTrigger);
}
});
// Reload contacts when tag filter changes (tag filtering is server-side)
let lastTagId: string | null = null;
$effect(() => {
const currentTagId = contactsFilterStore.selectedTagId;
if (currentTagId !== lastTagId) {
lastTagId = currentTagId;
if (currentTagId) {
contactsStore.setTagId(currentTagId);
} else {
contactsStore.setTagId(undefined);
}
contactsStore.loadContacts();
}
});
</script>
<div class="space-y-6">
@ -349,55 +367,6 @@
</div>
{/if}
<!-- Search Bar -->
<div class="relative">
<input
type="text"
placeholder={$_('contacts.search')}
bind:value={searchQuery}
oninput={handleSearch}
class="input w-full pl-10"
/>
<svg
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<!-- Unified Toolbar -->
<ContactsToolbar
contacts={contactsStore.contacts}
{sortField}
onSortFieldChange={(v) => (sortField = v)}
{contactFilter}
onContactFilterChange={(f) => (contactFilter = f)}
{birthdayFilter}
onBirthdayFilterChange={(f) => (birthdayFilter = f)}
{selectedTagId}
onTagChange={(id) => {
selectedTagId = id;
if (id) {
contactsStore.setTagId(id);
} else {
contactsStore.setTagId(undefined);
}
contactsStore.loadContacts();
}}
{selectedCompany}
onCompanyChange={(c) => (selectedCompany = c)}
{selectionMode}
onToggleSelectionMode={toggleSelectionMode}
/>
<!-- Loading state with skeleton -->
{#if contactsStore.loading}
{#if viewModeStore.mode === 'grid'}
@ -434,7 +403,7 @@
{selectionMode}
{selectedIds}
onToggleSelection={toggleSelection}
{sortField}
sortField={contactsFilterStore.sortField}
/>
{:else}
<ContactListView

View file

@ -1,274 +1,30 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { viewModeStore, type ViewMode } from '$lib/stores/view-mode.svelte';
import {
PillToolbar,
PillToolbarButton,
PillToolbarDivider,
PillViewSwitcher,
} from '@manacore/shared-ui';
import FilterBar, {
type ContactFilter,
type BirthdayFilter,
} from '$lib/components/FilterBar.svelte';
import { ExpandableToolbar } from '@manacore/shared-ui';
import ContactsToolbarContent from './ContactsToolbarContent.svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import type { Contact } from '$lib/api/contacts';
export type SortField = 'firstName' | 'lastName';
interface Props {
isSidebarMode?: boolean;
contacts: Contact[];
sortField: SortField;
onSortFieldChange: (field: SortField) => void;
contactFilter: ContactFilter;
onContactFilterChange: (filter: ContactFilter) => void;
birthdayFilter: BirthdayFilter;
onBirthdayFilterChange: (filter: BirthdayFilter) => void;
selectedTagId: string | null;
onTagChange: (tagId: string | null) => void;
selectedCompany: string | null;
onCompanyChange: (company: string | null) => void;
/** Selection mode state */
selectionMode: boolean;
/** Toggle selection mode callback */
onToggleSelectionMode: () => void;
}
let {
contacts,
sortField,
onSortFieldChange,
contactFilter,
onContactFilterChange,
birthdayFilter,
onBirthdayFilterChange,
selectedTagId,
onTagChange,
selectedCompany,
onCompanyChange,
selectionMode,
onToggleSelectionMode,
}: Props = $props();
let { isSidebarMode = false, contacts }: Props = $props();
// Count favorites for quick filter button
let favoritesCount = $derived(contactsStore.contacts.filter((c) => c.isFavorite).length);
let showFavorites = $derived(contactFilter === 'favorites');
// Use store for collapsed state
let isCollapsed = $derived(contactsFilterStore.isToolbarCollapsed);
// Sort options
const sortOptions = [
{ id: 'firstName', label: $_('sort.firstName'), title: $_('sort.firstName') },
{ id: 'lastName', label: $_('sort.lastName'), title: $_('sort.lastName') },
];
// View mode options
const viewOptions = [
{ id: 'list', label: '', title: $_('views.list'), icon: 'list' },
{ id: 'grid', label: '', title: $_('views.grid'), icon: 'grid' },
{ id: 'alphabet', label: '', title: $_('views.alphabet'), icon: 'alphabet' },
];
function toggleFavorites() {
if (contactFilter === 'favorites') {
onContactFilterChange('all');
} else {
onContactFilterChange('favorites');
}
}
function handleSortChange(value: string) {
onSortFieldChange(value as SortField);
}
function handleViewModeChange(value: string) {
viewModeStore.setMode(value as ViewMode);
function handleCollapsedChange(collapsed: boolean) {
contactsFilterStore.setToolbarCollapsed(collapsed);
}
</script>
<PillToolbar topOffset="70px">
<!-- New Contact Button -->
<PillToolbarButton onclick={() => goto('/contacts/new')} title={$_('contacts.new')}>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span class="btn-label">{$_('contacts.new')}</span>
</PillToolbarButton>
<PillToolbarDivider />
<!-- Selection Mode Toggle -->
<PillToolbarButton
onclick={onToggleSelectionMode}
active={selectionMode}
title={selectionMode ? 'Auswahl beenden' : 'Mehrere auswählen'}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
</PillToolbarButton>
<PillToolbarDivider />
<!-- Favorites Toggle -->
<PillToolbarButton
onclick={toggleFavorites}
active={showFavorites}
title={$_('filters.contact.favorites')}
>
<svg fill={showFavorites ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
{#if favoritesCount > 0}
<span class="count">{favoritesCount}</span>
{/if}
</PillToolbarButton>
<PillToolbarDivider />
<!-- Filter Dropdown -->
<FilterBar
{contacts}
{selectedTagId}
{onTagChange}
{contactFilter}
{onContactFilterChange}
{birthdayFilter}
{onBirthdayFilterChange}
{selectedCompany}
{onCompanyChange}
embedded={true}
/>
<PillToolbarDivider />
<!-- Sort Toggle -->
<PillViewSwitcher
options={sortOptions}
value={sortField}
onChange={handleSortChange}
primaryColor="#6366f1"
embedded={true}
/>
<PillToolbarDivider />
<!-- View Mode -->
<div class="view-mode-buttons">
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'list'}
onclick={() => viewModeStore.setMode('list')}
title={$_('views.list')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'grid'}
onclick={() => viewModeStore.setMode('grid')}
title={$_('views.grid')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"
/>
</svg>
</button>
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'alphabet'}
onclick={() => viewModeStore.setMode('alphabet')}
title={$_('views.alphabet')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"
/>
</svg>
</button>
</div>
</PillToolbar>
<style>
.btn-label {
display: none;
}
@media (min-width: 640px) {
.btn-label {
display: inline;
}
}
.count {
font-size: 0.75rem;
font-weight: 600;
}
.view-mode-buttons {
display: flex;
align-items: center;
gap: 0.125rem;
}
.view-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: transparent;
border: none;
border-radius: 9999px;
cursor: pointer;
color: #374151;
transition: all 0.15s ease;
}
:global(.dark) .view-btn {
color: #f3f4f6;
}
.view-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .view-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.view-btn.active {
background: color-mix(in srgb, #6366f1 15%, transparent 85%);
color: #6366f1;
}
.view-btn :global(svg) {
width: 1rem;
height: 1rem;
}
</style>
<ExpandableToolbar
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
{isSidebarMode}
collapsedTitle="Filter & Optionen"
expandedTitle="Schließen"
>
<ContactsToolbarContent {contacts} />
</ExpandableToolbar>

View file

@ -0,0 +1,161 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { PillViewSwitcher } from '@manacore/shared-ui';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import FilterBar from '$lib/components/FilterBar.svelte';
import type { Contact } from '$lib/api/contacts';
interface Props {
contacts: Contact[];
}
let { contacts }: Props = $props();
// Sort options
const sortOptions = [
{ id: 'firstName', label: $_('sort.firstName'), title: $_('sort.firstName') },
{ id: 'lastName', label: $_('sort.lastName'), title: $_('sort.lastName') },
];
function handleSortChange(value: string) {
contactsFilterStore.setSortField(value as 'firstName' | 'lastName');
}
</script>
<div class="toolbar-content-inner">
<!-- Filter Dropdown -->
<FilterBar
{contacts}
selectedTagId={contactsFilterStore.selectedTagId}
onTagChange={(id) => contactsFilterStore.setSelectedTagId(id)}
contactFilter={contactsFilterStore.contactFilter}
onContactFilterChange={(f) => contactsFilterStore.setContactFilter(f)}
birthdayFilter={contactsFilterStore.birthdayFilter}
onBirthdayFilterChange={(f) => contactsFilterStore.setBirthdayFilter(f)}
selectedCompany={contactsFilterStore.selectedCompany}
onCompanyChange={(c) => contactsFilterStore.setSelectedCompany(c)}
embedded={true}
/>
<div class="toolbar-divider"></div>
<!-- Sort Toggle -->
<PillViewSwitcher
options={sortOptions}
value={contactsFilterStore.sortField}
onChange={handleSortChange}
primaryColor="#3b82f6"
embedded={true}
/>
<div class="toolbar-divider"></div>
<!-- View Mode -->
<div class="view-mode-buttons">
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'list'}
onclick={() => viewModeStore.setMode('list')}
title={$_('views.list')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'grid'}
onclick={() => viewModeStore.setMode('grid')}
title={$_('views.grid')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"
/>
</svg>
</button>
<button
type="button"
class="view-btn"
class:active={viewModeStore.mode === 'alphabet'}
onclick={() => viewModeStore.setMode('alphabet')}
title={$_('views.alphabet')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12"
/>
</svg>
</button>
</div>
</div>
<style>
.toolbar-content-inner {
display: flex;
align-items: center;
gap: 0.5rem;
}
.toolbar-divider {
width: 1px;
height: 1.5rem;
background: hsl(var(--color-border));
margin: 0 0.25rem;
}
.view-mode-buttons {
display: flex;
align-items: center;
gap: 0.125rem;
}
.view-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
background: transparent;
border: none;
border-radius: 9999px;
cursor: pointer;
color: #374151;
transition: all 0.15s ease;
}
:global(.dark) .view-btn {
color: #f3f4f6;
}
.view-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .view-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.view-btn.active {
background: color-mix(in srgb, #3b82f6 15%, transparent 85%);
color: #3b82f6;
}
.view-btn :global(svg) {
width: 1rem;
height: 1rem;
}
</style>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { tagsApi, type ContactTag, type Contact } from '$lib/api/contacts';
export type ContactFilter = 'all' | 'favorites' | 'hasPhone' | 'hasEmail' | 'incomplete';
@ -37,6 +38,35 @@
let showFilters = $state(false);
let loadingTags = $state(true);
// Portal action - moves element to body to escape overflow constraints
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
},
};
}
// For embedded mode: trigger button ref and dropdown position
let triggerRef: HTMLButtonElement | undefined = $state();
let dropdownPosition = $state({ top: 0, left: 0 });
function toggleFilters() {
if (!showFilters && triggerRef) {
const rect = triggerRef.getBoundingClientRect();
dropdownPosition = {
top: rect.top - 8, // Position above the button
left: rect.left + rect.width / 2, // Center horizontally
};
}
showFilters = !showFilters;
}
function closeFilters() {
showFilters = false;
}
// Extract unique companies from contacts
let companies = $derived.by(() => {
const companySet = new Set<string>();
@ -88,10 +118,11 @@
<!-- Embedded mode: just the button for use in a toolbar -->
<div class="filter-bar-embedded">
<button
bind:this={triggerRef}
type="button"
class="filter-toggle-embedded"
class:active={showFilters || activeFilterCount > 0}
onclick={() => (showFilters = !showFilters)}
onclick={toggleFilters}
title={$_('filters.title')}
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -107,9 +138,23 @@
{/if}
</button>
<!-- Dropdown panel for embedded mode -->
<!-- Portal: Backdrop for click-outside -->
{#if showFilters}
<div class="filter-dropdown">
<button
use:portal
type="button"
class="filter-backdrop"
onclick={closeFilters}
aria-label="Close filters"
></button>
<!-- Portal: Dropdown panel -->
<div
use:portal
class="filter-dropdown-portal"
style="top: {dropdownPosition.top}px; left: {dropdownPosition.left}px;"
transition:fly={{ duration: 150, y: 8 }}
>
<!-- Tags Filter -->
<div class="filter-section">
<label class="filter-label">{$_('filters.tag')}</label>
@ -406,30 +451,94 @@
border-radius: 9999px;
}
.filter-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
left: 50%;
transform: translateX(-50%);
min-width: 280px;
padding: 1rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.75rem;
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.1),
0 8px 10px -6px rgba(0, 0, 0, 0.1);
z-index: 50;
display: flex;
flex-direction: column;
gap: 0.75rem;
/* Portal backdrop - full screen invisible button for click-outside */
:global(.filter-backdrop) {
position: fixed;
inset: 0;
z-index: 99990;
background: transparent;
border: none;
cursor: default;
}
:global(.dark) .filter-dropdown {
background: rgba(30, 30, 30, 0.95);
border-color: rgba(255, 255, 255, 0.1);
/* Portal dropdown - unified with ContextMenu design */
:global(.filter-dropdown-portal) {
position: fixed;
transform: translate(-50%, -100%);
min-width: 280px;
max-width: 320px;
padding: 0.375rem;
background: var(--color-surface-elevated-3, hsl(var(--color-surface)));
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-lg, 0.75rem);
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.15),
0 8px 10px -6px rgba(0, 0, 0, 0.1);
z-index: 99991;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
/* Portal dropdown sections - unified with ContextMenu item design */
:global(.filter-dropdown-portal) .filter-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.375rem 0.5rem;
border-radius: var(--radius-md, 0.5rem);
}
:global(.filter-dropdown-portal) .filter-label {
font-size: 0.6875rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.025em;
}
:global(.filter-dropdown-portal) .filter-select {
width: 100%;
padding: 0.5rem 0.625rem;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
background: hsl(var(--color-muted) / 0.5);
border: 1px solid transparent;
border-radius: var(--radius-md, 0.5rem);
cursor: pointer;
transition: all 100ms ease;
}
:global(.filter-dropdown-portal) .filter-select:hover {
background: hsl(var(--color-muted));
}
:global(.filter-dropdown-portal) .filter-select:focus {
outline: none;
border-color: hsl(var(--color-primary) / 0.5);
background: hsl(var(--color-muted));
}
:global(.filter-dropdown-portal) .clear-filters-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin-top: 0.25rem;
padding: 0.5rem 0.625rem;
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
background: transparent;
border: none;
border-radius: var(--radius-md, 0.5rem);
cursor: pointer;
transition: all 100ms ease;
}
:global(.filter-dropdown-portal) .clear-filters-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
/* Standard mode styles */

View file

@ -0,0 +1,961 @@
<script lang="ts">
import { onMount } from 'svelte';
import { contactsApi, photoApi } from '$lib/api/contacts';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
interface Props {
onClose: () => void;
onSuccess?: () => void;
}
let { onClose, onSuccess }: Props = $props();
let saving = $state(false);
let error = $state<string | null>(null);
let firstNameInput: HTMLInputElement;
let fileInput: HTMLInputElement;
// Photo state
let selectedPhoto = $state<File | null>(null);
let photoPreview = $state<string | null>(null);
// Form state
let firstName = $state('');
let lastName = $state('');
let email = $state('');
let phone = $state('');
let mobile = $state('');
let company = $state('');
let jobTitle = $state('');
let street = $state('');
let city = $state('');
let postalCode = $state('');
let country = $state('');
let notes = $state('');
const initials = $derived(() => {
const f = firstName?.[0] || '';
const l = lastName?.[0] || '';
return (f + l).toUpperCase() || '+';
});
const displayName = $derived(() => {
if (firstName || lastName) {
return [firstName, lastName].filter(Boolean).join(' ');
}
return email || 'Neuer Kontakt';
});
// Handle photo selection
function handlePhotoSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
// Validate file type
if (!file.type.startsWith('image/')) {
error = 'Bitte wähle eine Bilddatei aus';
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
error = 'Das Bild darf maximal 5MB groß sein';
return;
}
selectedPhoto = file;
// Create preview URL
photoPreview = URL.createObjectURL(file);
}
}
function removePhoto() {
if (photoPreview) {
URL.revokeObjectURL(photoPreview);
}
selectedPhoto = null;
photoPreview = null;
if (fileInput) {
fileInput.value = '';
}
}
// Populate form with prefill data if provided
function populateFromPrefill() {
const data = newContactModalStore.prefillData;
if (data) {
firstName = data.firstName || '';
lastName = data.lastName || '';
email = data.email || '';
phone = data.phone || '';
company = data.company || '';
// Parse displayName if no first/last name
if (data.displayName && !data.firstName && !data.lastName) {
const parts = data.displayName.split(' ');
firstName = parts[0] || '';
lastName = parts.slice(1).join(' ') || '';
}
}
}
async function handleSave() {
if (!firstName && !lastName && !email) {
error = 'Bitte mindestens Name oder E-Mail angeben';
return;
}
saving = true;
error = null;
try {
const contact = await contactsApi.create({
firstName: firstName || null,
lastName: lastName || null,
email: email || null,
phone: phone || null,
mobile: mobile || null,
company: company || null,
jobTitle: jobTitle || null,
street: street || null,
city: city || null,
postalCode: postalCode || null,
country: country || null,
notes: notes || null,
});
// Upload photo if selected
if (selectedPhoto && contact.id) {
try {
await photoApi.upload(contact.id, selectedPhoto);
} catch (photoError) {
console.error('Photo upload failed:', photoError);
// Don't fail the entire operation if photo upload fails
}
}
// Reload contacts list
await contactsStore.loadContacts();
onSuccess?.();
onClose();
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Erstellen';
} finally {
saving = false;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
onMount(() => {
populateFromPrefill();
document.body.style.overflow = 'hidden';
// Focus first input after a brief delay to ensure modal animation completes
setTimeout(() => {
firstNameInput?.focus();
}, 50);
return () => {
document.body.style.overflow = '';
// Clean up photo preview URL
if (photoPreview) {
URL.revokeObjectURL(photoPreview);
}
};
});
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Modal Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<!-- Header -->
<header class="modal-header">
<button onclick={onClose} class="back-button" aria-label="Schließen">
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<h1 id="modal-title" class="title">Neuer Kontakt</h1>
<div class="header-spacer"></div>
</header>
<!-- Modal Body -->
<div class="modal-body">
{#if error}
<div class="error-banner" role="alert">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>{error}</span>
</div>
{/if}
<!-- Avatar Preview with Photo Upload -->
<div class="avatar-section">
<input
type="file"
accept="image/*"
class="hidden-file-input"
bind:this={fileInput}
onchange={handlePhotoSelect}
/>
<div class="avatar-wrapper">
<button
type="button"
class="avatar-button"
onclick={() => fileInput?.click()}
title="Foto auswählen"
>
{#if photoPreview}
<img src={photoPreview} alt="Vorschau" class="avatar-image" />
<div class="avatar-overlay">
<svg class="camera-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
{:else}
<div class="avatar-circle empty">
<svg class="add-photo-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
{/if}
</button>
{#if photoPreview}
<button
type="button"
class="remove-photo-btn"
onclick={removePhoto}
title="Foto entfernen"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
<p class="preview-name">{displayName()}</p>
{#if company || jobTitle}
<p class="preview-subtitle">{[jobTitle, company].filter(Boolean).join(' bei ')}</p>
{/if}
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}
class="form"
>
<!-- Name Section -->
<section class="form-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
<h2 class="section-title">Name</h2>
</div>
<div class="form-grid">
<div class="form-field">
<label for="firstName" class="label">Vorname</label>
<input
id="firstName"
type="text"
bind:value={firstName}
bind:this={firstNameInput}
class="input"
placeholder="Max"
/>
</div>
<div class="form-field">
<label for="lastName" class="label">Nachname</label>
<input
id="lastName"
type="text"
bind:value={lastName}
class="input"
placeholder="Mustermann"
/>
</div>
</div>
</section>
<!-- Contact Section -->
<section class="form-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<h2 class="section-title">Kontakt</h2>
</div>
<div class="form-field">
<label for="email" class="label">E-Mail</label>
<input
id="email"
type="email"
bind:value={email}
class="input"
placeholder="max@example.com"
/>
</div>
<div class="form-grid">
<div class="form-field">
<label for="mobile" class="label">Mobil</label>
<input
id="mobile"
type="tel"
bind:value={mobile}
class="input"
placeholder="+49 170 1234567"
/>
</div>
<div class="form-field">
<label for="phone" class="label">Telefon</label>
<input
id="phone"
type="tel"
bind:value={phone}
class="input"
placeholder="+49 123 456789"
/>
</div>
</div>
</section>
<!-- Work Section -->
<section class="form-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<h2 class="section-title">Arbeit</h2>
</div>
<div class="form-field">
<label for="company" class="label">Firma</label>
<input
id="company"
type="text"
bind:value={company}
class="input"
placeholder="Musterfirma GmbH"
/>
</div>
<div class="form-field">
<label for="jobTitle" class="label">Position</label>
<input
id="jobTitle"
type="text"
bind:value={jobTitle}
class="input"
placeholder="Geschäftsführer"
/>
</div>
</section>
<!-- Address Section -->
<section class="form-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<h2 class="section-title">Adresse</h2>
</div>
<div class="form-field">
<label for="street" class="label">Straße & Hausnummer</label>
<input
id="street"
type="text"
bind:value={street}
class="input"
placeholder="Musterstraße 1"
/>
</div>
<div class="form-grid">
<div class="form-field">
<label for="postalCode" class="label">PLZ</label>
<input
id="postalCode"
type="text"
bind:value={postalCode}
class="input"
placeholder="12345"
/>
</div>
<div class="form-field">
<label for="city" class="label">Stadt</label>
<input id="city" type="text" bind:value={city} class="input" placeholder="Berlin" />
</div>
</div>
<div class="form-field">
<label for="country" class="label">Land</label>
<input
id="country"
type="text"
bind:value={country}
class="input"
placeholder="Deutschland"
/>
</div>
</section>
<!-- Notes Section -->
<section class="form-section">
<div class="section-header">
<div class="section-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</div>
<h2 class="section-title">Notizen</h2>
</div>
<textarea
bind:value={notes}
rows="4"
class="input textarea"
placeholder="Notizen zum Kontakt..."
></textarea>
</section>
<!-- Action Buttons -->
<div class="actions">
<button type="button" onclick={onClose} class="btn btn-secondary"> Abbrechen </button>
<button type="submit" disabled={saving} class="btn btn-primary">
{#if saving}
<svg class="spinner" viewBox="0 0 24 24" fill="none">
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
stroke-opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
<span>Speichern...</span>
{:else}
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
<span>Kontakt erstellen</span>
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal-container {
background: hsl(var(--color-background));
border-radius: 1.5rem;
width: 100%;
max-width: 560px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
animation: modalIn 0.2s ease-out;
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.modal-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid hsl(var(--color-border));
flex-shrink: 0;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 0 1.5rem 1.5rem;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.back-button:hover {
background: hsl(var(--color-surface-hover));
}
.title {
flex: 1;
font-size: 1.25rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.header-spacer {
width: 2.5rem;
}
.error-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: hsl(var(--color-error) / 0.1);
border: 1px solid hsl(var(--color-error) / 0.3);
border-radius: 0.75rem;
color: hsl(var(--color-error));
margin-bottom: 1rem;
margin-top: 1rem;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem 0;
}
.hidden-file-input {
display: none;
}
.avatar-wrapper {
position: relative;
margin-bottom: 1rem;
}
.avatar-button {
position: relative;
width: 100px;
height: 100px;
border-radius: 50%;
border: none;
padding: 0;
cursor: pointer;
overflow: hidden;
background: transparent;
}
.avatar-circle {
width: 100%;
height: 100%;
border-radius: 50%;
background: linear-gradient(
135deg,
hsl(var(--color-primary)) 0%,
hsl(var(--color-primary) / 0.7) 100%
);
color: hsl(var(--color-primary-foreground));
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: 700;
box-shadow: 0 8px 24px hsl(var(--color-primary) / 0.3);
position: relative;
}
.avatar-circle.empty {
background: hsl(var(--color-muted));
box-shadow: 0 4px 12px hsl(var(--color-foreground) / 0.1);
border: 2px dashed hsl(var(--color-border));
}
.avatar-button:hover .avatar-circle.empty {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.add-photo-icon {
width: 40px;
height: 40px;
color: hsl(var(--color-muted-foreground));
transition: color 0.2s ease;
}
.avatar-button:hover .add-photo-icon {
color: hsl(var(--color-primary));
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.avatar-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.avatar-button:hover .avatar-overlay {
opacity: 1;
}
.camera-icon {
width: 28px;
height: 28px;
color: white;
}
.remove-photo-btn {
position: absolute;
top: -4px;
right: -4px;
width: 28px;
height: 28px;
border-radius: 50%;
background: hsl(var(--color-error));
color: white;
border: 2px solid hsl(var(--color-background));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: transform 0.2s ease;
}
.remove-photo-btn:hover {
transform: scale(1.1);
}
.remove-photo-btn svg {
width: 14px;
height: 14px;
}
.preview-name {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
text-align: center;
}
.preview-subtitle {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
margin-top: 0.25rem;
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-section {
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.875rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.section-header {
display: flex;
align-items: center;
gap: 0.625rem;
padding-bottom: 0.625rem;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
margin-bottom: 0.25rem;
}
.section-icon {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
display: flex;
align-items: center;
justify-content: center;
}
.section-icon svg {
width: 1rem;
height: 1rem;
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.input {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1.5px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-input));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
transition: all 0.2s ease;
}
.input:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
.input::placeholder {
color: hsl(var(--color-muted-foreground) / 0.6);
}
.textarea {
resize: none;
min-height: 80px;
}
.actions {
display: flex;
gap: 0.75rem;
padding-top: 0.5rem;
}
.btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-radius: 0.625rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.3);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 16px hsl(var(--color-primary) / 0.4);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.btn-secondary {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.btn-secondary:hover {
background: hsl(var(--color-surface-hover));
}
.icon {
width: 1.25rem;
height: 1.25rem;
}
.icon-sm {
width: 1rem;
height: 1rem;
}
.spinner {
width: 1.25rem;
height: 1.25rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 480px) {
.modal-container {
max-height: 95vh;
border-radius: 1rem 1rem 0 0;
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-width: 100%;
}
.modal-backdrop {
padding: 0;
align-items: flex-end;
}
.form-grid {
grid-template-columns: 1fr;
}
.actions {
flex-direction: column-reverse;
}
}
</style>

View file

@ -2,6 +2,7 @@
import { _ } from 'svelte-i18n';
import type { Contact } from '$lib/api/contacts';
import type { SortField } from '$lib/components/SortToggle.svelte';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
interface Props {
contacts: Contact[];
@ -11,6 +12,7 @@
selectedIds?: Set<string>;
onToggleSelection?: (id: string) => void;
sortField?: SortField;
showNewContactCard?: boolean;
}
let {
@ -21,6 +23,7 @@
selectedIds = new Set(),
onToggleSelection,
sortField = 'lastName',
showNewContactCard = true,
}: Props = $props();
function handleCheckboxClick(e: MouseEvent, id: string) {
@ -87,6 +90,39 @@
</script>
<div class="alphabet-view">
<!-- New Contact Card at top -->
{#if showNewContactCard && !selectionMode}
<div class="new-contact-section">
<div
role="button"
tabindex="0"
onclick={() => newContactModalStore.open()}
onkeydown={(e) => e.key === 'Enter' && newContactModalStore.open()}
class="alphabet-contact-card new-contact-card"
>
<!-- Plus Avatar -->
<div class="avatar-sm new-contact-avatar">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</div>
<!-- Text -->
<div class="contact-info">
<div class="contact-main-row">
<span class="contact-name">{$_('contacts.new')}</span>
<span class="contact-company-inline">{$_('contacts.addFirst')}</span>
</div>
</div>
</div>
</div>
{/if}
<!-- Contacts grouped by letter -->
<div class="alphabet-sections">
{#each availableLetters as letter}
@ -142,30 +178,46 @@
<!-- Contact Info -->
<div class="contact-info">
<div class="contact-name">
{getDisplayName(contact)}
</div>
<div class="contact-details">
{#if contact.jobTitle && contact.company}
<span>{contact.jobTitle} @ {contact.company}</span>
{:else if contact.company}
<span>{contact.company}</span>
{:else if contact.email}
<span>{contact.email}</span>
<div class="contact-main-row">
<span class="contact-name">{getDisplayName(contact)}</span>
{#if contact.isFavorite}
<svg class="favorite-badge" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
{/if}
{#if contact.company}
<span class="contact-company-inline">@ {contact.company}</span>
{/if}
</div>
{#if contact.tags && contact.tags.length > 0}
<div class="contact-tags-row">
{#each contact.tags.slice(0, 3) as tag}
<span
class="tag-chip"
style="--tag-color: {tag.color || 'hsl(var(--primary))'}"
>
{tag.name}
</span>
{/each}
{#if contact.tags.length > 3}
<span class="tag-chip more-tags">+{contact.tags.length - 3}</span>
{/if}
</div>
{/if}
</div>
<!-- Quick Actions -->
<div class="quick-actions">
<!-- Action Icons (right side) -->
<div class="contact-actions">
{#if contact.phone || contact.mobile}
<a
href="tel:{contact.mobile || contact.phone}"
onclick={(e) => e.stopPropagation()}
class="quick-action-btn"
title={$_('contacts.call')}
class="action-chip"
title={contact.mobile || contact.phone}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -179,10 +231,10 @@
<a
href="mailto:{contact.email}"
onclick={(e) => e.stopPropagation()}
class="quick-action-btn"
title={$_('contacts.email')}
class="action-chip"
title={contact.email}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -192,28 +244,6 @@
</svg>
</a>
{/if}
<button
onclick={(e) => onToggleFavorite(e, contact.id)}
class="quick-action-btn"
title={contact.isFavorite ? $_('contacts.unfavorite') : $_('contacts.favorite')}
>
{#if contact.isFavorite}
<svg class="w-4 h-4 text-red-500 fill-current" viewBox="0 0 24 24">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
{/if}
</button>
</div>
</div>
{/each}
@ -222,37 +252,38 @@
{/each}
</div>
<!-- Alphabet Quick Jump -->
<!-- Alphabet Quick Jump (like DateStrip) -->
<div class="alphabet-nav">
{#each alphabet as letter}
<button
type="button"
class="alphabet-nav-btn"
class:active={availableLetters.includes(letter)}
class:disabled={!availableLetters.includes(letter)}
onclick={() => availableLetters.includes(letter) && scrollToLetter(letter)}
disabled={!availableLetters.includes(letter)}
>
{letter}
</button>
{/each}
{#if availableLetters.includes('#')}
<button type="button" class="alphabet-nav-btn active" onclick={() => scrollToLetter('#')}>
#
</button>
{/if}
<div class="alphabet-nav-container">
{#each alphabet as letter}
<button
type="button"
class="alphabet-nav-btn"
class:active={availableLetters.includes(letter)}
class:disabled={!availableLetters.includes(letter)}
onclick={() => availableLetters.includes(letter) && scrollToLetter(letter)}
disabled={!availableLetters.includes(letter)}
>
{letter}
</button>
{/each}
{#if availableLetters.includes('#')}
<button type="button" class="alphabet-nav-btn active" onclick={() => scrollToLetter('#')}>
#
</button>
{/if}
</div>
</div>
</div>
<style>
.alphabet-view {
display: flex;
gap: 1rem;
display: block;
position: relative;
padding-bottom: 10rem; /* Space for fixed alphabet nav + InputBar */
}
.alphabet-sections {
flex: 1;
min-width: 0;
}
@ -303,20 +334,17 @@
gap: 0.5rem;
}
.section-contacts .alphabet-contact-card:last-child {
margin-bottom: 1rem;
}
.alphabet-contact-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
background-color: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
min-width: 0;
}
.alphabet-contact-card:hover {
@ -325,9 +353,9 @@
}
.avatar-sm {
width: 52px;
height: 52px;
min-width: 52px;
width: 36px;
height: 36px;
min-width: 36px;
border-radius: var(--radius-full);
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
@ -335,60 +363,101 @@
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 1.125rem;
font-size: 0.8125rem;
flex-shrink: 0;
}
.contact-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.contact-main-row {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.contact-name {
font-weight: 500;
font-size: 0.9375rem;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contact-details {
font-size: 0.875rem;
.favorite-badge {
width: 0.8125rem;
height: 0.8125rem;
color: hsl(var(--destructive, 0 84% 60%));
flex-shrink: 0;
}
.contact-company-inline {
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quick-actions {
.contact-tags-row {
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transition: opacity var(--transition-fast);
flex-wrap: wrap;
}
.alphabet-contact-card:hover .quick-actions {
opacity: 1;
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.0625rem 0.375rem;
font-size: 0.625rem;
font-weight: 500;
border-radius: 9999px;
background: var(--tag-color, hsl(var(--primary)));
color: white;
white-space: nowrap;
}
.quick-action-btn {
.tag-chip.more-tags {
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
}
.contact-actions {
display: flex;
align-items: center;
gap: 0.375rem;
flex-shrink: 0;
}
.action-chip {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-full);
background-color: hsl(var(--muted));
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
transition: all var(--transition-fast);
border: none;
cursor: pointer;
text-decoration: none;
transition: all 0.15s ease;
}
.quick-action-btn:hover {
background-color: hsl(var(--primary));
.action-chip:hover {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.action-icon {
width: 1rem;
height: 1rem;
}
.selection-checkbox {
display: flex;
align-items: center;
@ -412,44 +481,71 @@
border-color: hsl(var(--primary) / 0.3);
}
/* Alphabet Navigation */
/* Alphabet Navigation - Horizontal strip above InputBar + PillNav (like DateStrip) */
.alphabet-nav {
position: sticky;
top: 80px;
position: fixed;
bottom: calc(140px + env(safe-area-inset-bottom, 0px)); /* Above PillNav + InputBar */
left: 0;
right: 0;
z-index: 48;
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.5rem 0.25rem;
height: fit-content;
/* Glass effect */
background: hsl(var(--background) / 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: var(--radius-lg);
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05);
align-items: stretch;
pointer-events: none;
transition: bottom 0.2s ease;
}
@media (min-width: 769px) and (max-width: 1024px) {
.alphabet-nav {
top: 80px;
}
.alphabet-nav-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
padding: 0.5rem 0;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
scroll-behavior: smooth;
/* Glass container like DateStrip */
background: var(--color-surface, hsl(var(--background)));
border-radius: 16px;
margin: 0 1rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
border: 1px solid hsl(var(--border));
pointer-events: auto;
/* Fade effect at edges */
mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
-webkit-mask-image: linear-gradient(
to right,
transparent 0%,
black 5%,
black 95%,
transparent 100%
);
}
.alphabet-nav-container::-webkit-scrollbar {
display: none;
}
.alphabet-nav-btn {
width: 1.75rem;
height: 1.5rem;
min-width: 44px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 500;
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
border-radius: var(--radius-sm);
border-radius: 10px;
cursor: pointer;
transition: all var(--transition-fast);
transition: all 0.15s ease;
flex-shrink: 0;
}
.alphabet-nav-btn:hover {
background: hsl(var(--muted));
}
.alphabet-nav-btn.active {
@ -462,44 +558,50 @@
}
.alphabet-nav-btn.disabled {
color: hsl(var(--muted-foreground) / 0.3);
color: hsl(var(--muted-foreground) / 0.2);
cursor: default;
}
/* Mobile: Hide alphabet nav, show horizontal version at bottom */
@media (max-width: 768px) {
.alphabet-view {
flex-direction: column;
}
.alphabet-nav-btn.disabled:hover {
background: transparent;
}
.alphabet-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 0.25rem;
padding: 0.5rem;
border-radius: 0;
border-left: none;
border-right: none;
border-bottom: none;
z-index: 50;
}
/* New Contact Card */
.new-contact-section {
margin-bottom: 1rem;
}
.alphabet-nav-btn {
width: 1.5rem;
height: 1.5rem;
}
.new-contact-card {
border-style: dashed;
border-color: hsl(var(--primary) / 0.4);
background: hsl(var(--primary) / 0.05);
max-width: 280px;
}
.alphabet-sections {
padding-bottom: 4rem;
}
.new-contact-card:hover {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.quick-actions {
opacity: 1;
}
.new-contact-avatar {
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
}
.new-contact-avatar svg {
width: 1.125rem;
height: 1.125rem;
}
.new-contact-card .contact-info {
gap: 0;
}
.new-contact-card .contact-name {
font-size: 0.875rem;
}
.new-contact-card .contact-company-inline {
font-size: 0.75rem;
}
</style>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { Contact } from '$lib/api/contacts';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
interface Props {
contacts: Contact[];
@ -9,6 +10,7 @@
selectionMode?: boolean;
selectedIds?: Set<string>;
onToggleSelection?: (id: string) => void;
showNewContactCard?: boolean;
}
let {
@ -18,6 +20,7 @@
selectionMode = false,
selectedIds = new Set(),
onToggleSelection,
showNewContactCard = true,
}: Props = $props();
function handleCheckboxClick(e: MouseEvent, id: string) {
@ -58,6 +61,35 @@
</script>
<div class="contact-grid">
<!-- New Contact Card -->
{#if showNewContactCard && !selectionMode}
<div
role="button"
tabindex="0"
onclick={() => newContactModalStore.open()}
onkeydown={(e) => e.key === 'Enter' && newContactModalStore.open()}
class="grid-card new-contact-card"
>
<!-- Plus Avatar -->
<div class="grid-avatar new-contact-avatar">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</div>
<!-- Info -->
<div class="grid-info">
<h3 class="grid-name">{$_('contacts.new')}</h3>
<p class="grid-job">{$_('contacts.addFirst')}</p>
</div>
</div>
{/if}
{#each contacts as contact (contact.id)}
<div
role="button"
@ -335,4 +367,22 @@
background: hsl(var(--primary) / 0.1) !important;
border-color: hsl(var(--primary) / 0.3) !important;
}
/* New Contact Card */
.new-contact-card {
border-style: dashed;
border-color: hsl(var(--primary) / 0.4);
background: hsl(var(--primary) / 0.05);
}
.new-contact-card:hover {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
transform: translateY(-4px);
}
.new-contact-avatar {
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
}
</style>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { Contact } from '$lib/api/contacts';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
interface Props {
contacts: Contact[];
@ -9,6 +10,7 @@
selectionMode?: boolean;
selectedIds?: Set<string>;
onToggleSelection?: (id: string) => void;
showNewContactCard?: boolean;
}
let {
@ -18,6 +20,7 @@
selectionMode = false,
selectedIds = new Set(),
onToggleSelection,
showNewContactCard = true,
}: Props = $props();
function getInitials(contact: Contact) {
@ -41,6 +44,39 @@
</script>
<div class="space-y-2">
<!-- New Contact Card -->
{#if showNewContactCard && !selectionMode}
<div
role="button"
tabindex="0"
onclick={() => newContactModalStore.open()}
onkeydown={(e) => e.key === 'Enter' && newContactModalStore.open()}
class="contact-card new-contact-card w-full text-left cursor-pointer"
>
<!-- Plus Avatar -->
<div class="avatar new-contact-avatar">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</div>
<!-- Text -->
<div class="flex-1 min-w-0">
<div class="font-medium text-foreground">
{$_('contacts.new')}
</div>
<div class="text-sm text-muted-foreground">
{$_('contacts.addFirst')}
</div>
</div>
</div>
{/if}
{#each contacts as contact (contact.id)}
<div
role="button"
@ -154,4 +190,21 @@
background: hsl(var(--color-primary) / 0.1) !important;
border-color: hsl(var(--color-primary) / 0.3) !important;
}
/* New Contact Card */
.new-contact-card {
border-style: dashed;
border-color: hsl(var(--color-primary) / 0.4);
background: hsl(var(--color-primary) / 0.05);
}
.new-contact-card:hover {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.new-contact-avatar {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
}
</style>

View file

@ -0,0 +1,146 @@
/**
* Filter Store - Manages filter state for the Contacts app toolbar
* Uses Svelte 5 runes for reactivity
*/
import { browser } from '$app/environment';
export type SortField = 'firstName' | 'lastName';
export type ContactFilter = 'all' | 'favorites' | 'hasPhone' | 'hasEmail' | 'incomplete';
export type BirthdayFilter = 'all' | 'today' | 'thisWeek' | 'thisMonth';
export interface ContactsFilterState {
sortField: SortField;
contactFilter: ContactFilter;
birthdayFilter: BirthdayFilter;
selectedTagId: string | null;
selectedCompany: string | null;
isToolbarCollapsed: boolean;
searchQuery: string;
}
const DEFAULT_STATE: ContactsFilterState = {
sortField: 'lastName',
contactFilter: 'all',
birthdayFilter: 'all',
selectedTagId: null,
selectedCompany: null,
isToolbarCollapsed: true,
searchQuery: '',
};
const STORAGE_KEY = 'contacts-filter-state';
function loadState(): ContactsFilterState {
if (!browser) return DEFAULT_STATE;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return { ...DEFAULT_STATE, ...parsed };
}
} catch (e) {
console.error('Failed to load contacts filter state:', e);
}
return DEFAULT_STATE;
}
function saveState(state: ContactsFilterState) {
if (!browser) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (e) {
console.error('Failed to save contacts filter state:', e);
}
}
// Reactive state
let state = $state<ContactsFilterState>(DEFAULT_STATE);
export const contactsFilterStore = {
// Getters
get sortField() {
return state.sortField;
},
get contactFilter() {
return state.contactFilter;
},
get birthdayFilter() {
return state.birthdayFilter;
},
get selectedTagId() {
return state.selectedTagId;
},
get selectedCompany() {
return state.selectedCompany;
},
get isToolbarCollapsed() {
return state.isToolbarCollapsed;
},
get searchQuery() {
return state.searchQuery;
},
// Setters
setSortField(value: SortField) {
state = { ...state, sortField: value };
saveState(state);
},
setContactFilter(value: ContactFilter) {
state = { ...state, contactFilter: value };
saveState(state);
},
setBirthdayFilter(value: BirthdayFilter) {
state = { ...state, birthdayFilter: value };
saveState(state);
},
setSelectedTagId(value: string | null) {
state = { ...state, selectedTagId: value };
saveState(state);
},
setSelectedCompany(value: string | null) {
state = { ...state, selectedCompany: value };
saveState(state);
},
setToolbarCollapsed(value: boolean) {
state = { ...state, isToolbarCollapsed: value };
saveState(state);
},
toggleToolbar() {
state = { ...state, isToolbarCollapsed: !state.isToolbarCollapsed };
saveState(state);
},
setSearchQuery(value: string) {
state = { ...state, searchQuery: value };
// Don't persist search query to localStorage
},
// Reset filters (but not toolbar state)
resetFilters() {
state = {
...state,
contactFilter: 'all',
birthdayFilter: 'all',
selectedTagId: null,
selectedCompany: null,
searchQuery: '',
};
saveState(state);
},
// Initialize from localStorage
initialize() {
if (!browser) return;
state = loadState();
},
};

View file

@ -0,0 +1,41 @@
/**
* Store for controlling the New Contact Modal
*/
interface NewContactData {
firstName?: string;
lastName?: string;
displayName?: string;
email?: string;
phone?: string;
company?: string;
}
let isOpen = $state(false);
let prefillData = $state<NewContactData | null>(null);
export const newContactModalStore = {
get isOpen() {
return isOpen;
},
get prefillData() {
return prefillData;
},
/**
* Open the modal, optionally with pre-filled data
*/
open(data?: NewContactData) {
prefillData = data || null;
isOpen = true;
},
/**
* Close the modal and reset data
*/
close() {
isOpen = false;
prefillData = null;
},
};

View file

@ -34,15 +34,19 @@
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
import NewContactModal from '$lib/components/NewContactModal.svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
import { contactsApi, tagsApi } from '$lib/api/contacts';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import { contactsSettings } from '$lib/stores/settings.svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import {
parseContactInput,
resolveContactIds,
formatParsedContactPreview,
} from '$lib/utils/contact-parser';
import ContactsToolbar from '$lib/components/ContactsToolbar.svelte';
// Tags state for Quick-Create
let availableTags = $state<{ id: string; name: string }[]>([]);
@ -68,6 +72,18 @@
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Show toolbar only on main contacts page
const showContactsToolbar = $derived($page.url.pathname === '/' && !isSidebarMode);
// Dynamic bottom offset based on toolbar state
const inputBarBottomOffset = $derived(
isSidebarMode
? '0px'
: showContactsToolbar && !contactsFilterStore.isToolbarCollapsed
? '140px'
: '70px'
);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
@ -227,30 +243,21 @@
async function handleCreate(query: string): Promise<void> {
const parsed = parseContactInput(query);
if (!parsed.displayName) return;
// Resolve tag names to IDs
const resolved = resolveContactIds(parsed, availableTags);
try {
const contact = await contactsStore.createContact({
displayName: resolved.displayName,
firstName: resolved.firstName,
lastName: resolved.lastName,
company: resolved.company,
email: resolved.email,
phone: resolved.phone,
});
// Add tags to the created contact
if (resolved.tagIds.length > 0 && contact) {
for (const tagId of resolved.tagIds) {
await tagsApi.addToContact(tagId, contact.id);
}
}
} catch (e) {
console.error('Failed to create contact:', e);
if (!parsed.displayName) {
// If no query, just open empty modal
newContactModalStore.open();
return;
}
// Open modal with prefilled data
newContactModalStore.open({
displayName: parsed.displayName,
firstName: parsed.firstName || undefined,
lastName: parsed.lastName || undefined,
email: parsed.email || undefined,
phone: parsed.phone || undefined,
company: parsed.company || undefined,
});
}
// QuickInputBar quick actions
@ -281,9 +288,10 @@
console.error('Failed to load tags:', e);
}
// Initialize contacts settings and view mode
// Initialize contacts settings, view mode, and filter store
contactsSettings.initialize();
viewModeStore.initialize();
contactsFilterStore.initialize();
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('contacts-nav-sidebar');
@ -306,10 +314,7 @@
<SplitPaneContainer>
<!-- Navigation Layout -->
<div class="layout-container">
<!-- Shadow gradient above navigation -->
<div class="nav-shadow-gradient"></div>
<!-- Floating/Sidebar Pill Navigation -->
<!-- Floating/Sidebar Pill Navigation (at bottom) -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
@ -321,7 +326,7 @@
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
desktopPosition={userSettings.nav.desktopPosition}
desktopPosition="bottom"
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
@ -361,10 +366,16 @@
<ContactDetailModal contactId={modalContactId} onClose={handleCloseContactModal} />
{/if}
<!-- New Contact Modal -->
{#if newContactModalStore.isOpen}
<NewContactModal onClose={() => newContactModalStore.close()} />
{/if}
<!-- Global Quick Input Bar -->
<QuickInputBar
onSearch={handleSearch}
onSelect={handleSelect}
onSearchChange={(query) => contactsFilterStore.setSearchQuery(query)}
{quickActions}
placeholder="Neuer Kontakt oder suchen..."
emptyText="Keine Kontakte gefunden"
@ -374,8 +385,15 @@
createText="Erstellen"
appIcon="contacts"
primaryColor="#3b82f6"
autoFocus={false}
autoFocus={true}
bottomOffset={inputBarBottomOffset}
hasFabRight={showContactsToolbar}
/>
<!-- Contacts Toolbar (FAB + expandable bar) - only on main page -->
{#if showContactsToolbar}
<ContactsToolbar {isSidebarMode} contacts={contactsStore.contacts} />
{/if}
</div>
</SplitPaneContainer>
@ -389,17 +407,19 @@
.main-content {
flex: 1;
transition: all 300ms ease;
/* Space for QuickInputBar + PillNav at bottom */
padding-bottom: calc(150px + env(safe-area-inset-bottom));
}
/* Floating nav mode - add top padding for fixed nav */
/* Floating nav mode - nav is at bottom, no top padding needed */
.main-content.floating-mode {
padding-top: 80px;
padding-top: 0;
}
/* Extra padding on mobile for larger nav */
/* Extra bottom padding on mobile */
@media (max-width: 768px) {
.main-content.floating-mode {
padding-top: 90px;
.main-content {
padding-bottom: calc(160px + env(safe-area-inset-bottom));
}
}
@ -426,27 +446,4 @@
padding: 2rem;
}
}
/* Shadow gradient above pill navigation */
.nav-shadow-gradient {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 80px;
background: linear-gradient(
to bottom,
hsl(var(--background)) 0%,
hsl(var(--background)) 50%,
hsl(var(--background) / 0) 100%
);
pointer-events: none;
z-index: 40;
}
@media (max-width: 768px) {
.nav-shadow-gradient {
height: 90px;
}
}
</style>

View file

@ -2,11 +2,17 @@
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { networkStore, type SimulationNode } from '$lib/stores/network.svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import { NetworkGraph, NetworkControls } from '@manacore/shared-ui';
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
import { NetworkGraphSkeleton } from '$lib/components/skeletons';
import '$lib/i18n';
// Sync global search to network store
$effect(() => {
networkStore.setSearch(contactsFilterStore.searchQuery);
});
let graphComponent: NetworkGraph;
let controlsComponent: NetworkControls;
let graphContainer: HTMLDivElement;
@ -110,7 +116,7 @@
<div class="controls-wrapper">
<NetworkControls
bind:this={controlsComponent}
searchQuery={networkStore.searchQuery}
showSearch={false}
tags={networkStore.uniqueTags}
selectedTagId={networkStore.filterTagId}
subtitles={networkStore.uniqueCompanies}
@ -120,9 +126,7 @@
linkCount={networkStore.links.length}
nodeLabel="Kontakte"
linkLabel="Verbindungen"
searchPlaceholder="Kontakt suchen..."
minStrength={networkStore.minStrength}
onSearch={handleSearch}
onTagFilter={handleTagFilter}
onSubtitleFilter={handleSubtitleFilter}
onStrengthFilter={handleStrengthFilter}
@ -189,8 +193,9 @@
/* Floating Controls */
.controls-wrapper {
position: absolute;
top: 5rem; /* Below the nav */
left: 1rem;
top: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 10;
max-width: calc(100% - 2rem);
}
@ -297,8 +302,8 @@
@media (max-width: 768px) {
.controls-wrapper {
top: 6rem; /* Larger nav on mobile */
width: calc(100% - 1rem);
top: 1rem;
width: calc(100% - 2rem);
max-width: none;
}
}

View file

@ -88,6 +88,7 @@ export {
PillToolbar,
PillToolbarButton,
PillToolbarDivider,
ExpandableToolbar,
} from './navigation';
export type {
NavItem,
@ -100,6 +101,7 @@ export type {
PillNavElement,
PillNavigationProps,
PillTabOption,
ExpandableToolbarProps,
} from './navigation';
// Settings

View file

@ -0,0 +1,223 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import type { Snippet } from 'svelte';
interface Props {
/** Whether the toolbar is collapsed */
isCollapsed?: boolean;
/** Called when collapsed state changes */
onCollapsedChange?: (isCollapsed: boolean) => void;
/** Whether in sidebar mode (affects positioning) */
isSidebarMode?: boolean;
/** Bottom offset from viewport bottom (default: '70px') */
bottomOffset?: string;
/** Sidebar mode bottom offset (default: '0px') */
sidebarBottomOffset?: string;
/** Panel height when expanded (default: '70px') */
panelHeight?: string;
/** FAB tooltip when collapsed */
collapsedTitle?: string;
/** FAB tooltip when expanded */
expandedTitle?: string;
/** Custom collapsed icon snippet */
collapsedIcon?: Snippet;
/** Custom expanded icon snippet */
expandedIcon?: Snippet;
/** Panel content (required) */
children: Snippet;
/** Optional right-side content (e.g., layout toggle) */
rightActions?: Snippet;
}
let {
isCollapsed = true,
onCollapsedChange,
isSidebarMode = false,
bottomOffset = '70px',
sidebarBottomOffset = '0px',
panelHeight = '70px',
collapsedTitle = 'Optionen',
expandedTitle = 'Schließen',
collapsedIcon,
expandedIcon,
children,
rightActions,
}: Props = $props();
function toggleToolbar() {
onCollapsedChange?.(!isCollapsed);
}
</script>
<!-- FAB Button - positioned next to InputBar -->
<div
class="fab-container"
class:sidebar-mode={isSidebarMode}
class:expanded={!isCollapsed}
style="--bottom-offset: {isSidebarMode
? sidebarBottomOffset
: bottomOffset}; --panel-height: {panelHeight};"
>
<button
onclick={toggleToolbar}
class="toolbar-fab glass-pill"
class:active={!isCollapsed}
title={isCollapsed ? collapsedTitle : expandedTitle}
>
<svg class="fab-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if isCollapsed}
{#if collapsedIcon}
{@render collapsedIcon()}
{:else}
<!-- Default settings/sliders icon -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
/>
{/if}
{:else if expandedIcon}
{@render expandedIcon()}
{:else}
<!-- Default chevron down icon -->
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
{/if}
</svg>
</button>
</div>
<!-- Expanded Toolbar Panel - below InputBar, pushes content up -->
{#if !isCollapsed}
<div
class="toolbar-bar glass-panel"
class:sidebar-mode={isSidebarMode}
style="--bottom-offset: {isSidebarMode ? sidebarBottomOffset : bottomOffset};"
transition:slide={{ duration: 200 }}
>
<div class="toolbar-content">
{@render children()}
{#if rightActions}
<div class="toolbar-divider"></div>
{@render rightActions()}
{/if}
</div>
</div>
{/if}
<style>
/* FAB Container - positioned next to InputBar (aligned with input-container) */
.fab-container {
position: fixed;
bottom: calc(
var(--bottom-offset, 70px) + 9px + env(safe-area-inset-bottom, 0px)
); /* base offset + 9px to align with input-container */
right: calc(50% - 350px - 70px); /* Right of InputBar (max-width 700px / 2 + gap) */
z-index: 91; /* Above InputBar (90) */
pointer-events: none;
transition: bottom 0.2s ease;
}
/* When expanded, move FAB up with InputBar */
.fab-container.expanded {
bottom: calc(
var(--bottom-offset, 70px) + var(--panel-height, 70px) + 9px +
env(safe-area-inset-bottom, 0px)
);
}
/* Responsive positioning */
@media (max-width: 900px) {
.fab-container {
right: 1rem;
}
}
/* Toolbar Bar - full width below InputBar */
.toolbar-bar {
position: fixed;
bottom: calc(var(--bottom-offset, 70px) + env(safe-area-inset-bottom, 0px));
left: 0;
right: 0;
z-index: 89; /* Below InputBar (90) */
display: flex;
justify-content: center;
padding: 0.5rem 1rem;
}
.toolbar-content {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: hsl(var(--color-surface) / 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid hsl(var(--color-border));
box-shadow: 0 -2px 16px hsl(var(--color-foreground) / 0.08);
border-radius: 1rem;
white-space: nowrap;
max-width: calc(100vw - 2rem);
/* Allow dropdowns to overflow */
overflow: visible;
}
/* Glass styling */
.glass-pill {
background: hsl(var(--color-surface) / 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-border));
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.08);
border-radius: 9999px;
}
.glass-panel {
background: transparent;
}
/* FAB Button - same height as InputBar (54px) */
.toolbar-fab {
display: flex;
align-items: center;
justify-content: center;
width: 54px;
height: 54px;
cursor: pointer;
border: none;
transition: all 0.2s ease;
pointer-events: auto;
}
.toolbar-fab:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--color-foreground) / 0.15);
}
.toolbar-fab.active {
background: hsl(var(--color-muted));
}
.toolbar-fab.active .fab-icon {
color: hsl(var(--color-primary));
}
.fab-icon {
width: 1.5rem;
height: 1.5rem;
color: hsl(var(--color-muted-foreground));
transition: color 0.2s ease;
}
.toolbar-fab:hover .fab-icon {
color: hsl(var(--color-foreground));
}
.toolbar-divider {
width: 1px;
height: 1.5rem;
background: hsl(var(--color-border));
margin: 0 0.25rem;
}
</style>

View file

@ -0,0 +1,2 @@
export { default as ExpandableToolbar } from './ExpandableToolbar.svelte';
export type { ExpandableToolbarProps } from './types';

View file

@ -0,0 +1,28 @@
import type { Snippet } from 'svelte';
export interface ExpandableToolbarProps {
/** Whether the toolbar is collapsed */
isCollapsed?: boolean;
/** Called when collapsed state changes */
onCollapsedChange?: (isCollapsed: boolean) => void;
/** Whether in sidebar mode (affects positioning) */
isSidebarMode?: boolean;
/** Bottom offset from viewport bottom (default: '70px') */
bottomOffset?: string;
/** Sidebar mode bottom offset (default: '0px') */
sidebarBottomOffset?: string;
/** Panel height when expanded (default: '70px') */
panelHeight?: string;
/** FAB tooltip when collapsed */
collapsedTitle?: string;
/** FAB tooltip when expanded */
expandedTitle?: string;
/** Custom collapsed icon snippet */
collapsedIcon?: Snippet;
/** Custom expanded icon snippet */
expandedIcon?: Snippet;
/** Panel content (required) */
children: Snippet;
/** Optional right-side content (e.g., layout toggle) */
rightActions?: Snippet;
}

View file

@ -10,6 +10,8 @@ export { default as PillViewSwitcher } from './PillViewSwitcher.svelte';
export { default as PillToolbar } from './PillToolbar.svelte';
export { default as PillToolbarButton } from './PillToolbarButton.svelte';
export { default as PillToolbarDivider } from './PillToolbarDivider.svelte';
export { ExpandableToolbar } from './expandable-toolbar';
export type { ExpandableToolbarProps } from './expandable-toolbar';
export type {
NavItem,
NavbarProps,

View file

@ -15,6 +15,7 @@
linkLabel?: string;
searchPlaceholder?: string;
minStrength?: number;
showSearch?: boolean;
onSearch?: (query: string) => void;
onTagFilter?: (tagId: string | null) => void;
onSubtitleFilter?: (subtitle: string | null) => void;
@ -39,6 +40,7 @@
linkLabel = 'Verbindungen',
searchPlaceholder = 'Suchen...',
minStrength = 0,
showSearch = true,
onSearch,
onTagFilter,
onSubtitleFilter,
@ -122,22 +124,24 @@
<div class="network-controls">
<!-- Search bar -->
<div class="search-container">
<Search size={18} class="search-icon" />
<input
bind:this={searchInputElement}
type="text"
placeholder={searchPlaceholder}
value={searchInput}
oninput={handleSearchInput}
class="search-input"
/>
{#if searchInput}
<button onclick={clearSearch} class="clear-btn" aria-label="Suche löschen">
<X size={16} />
</button>
{/if}
</div>
{#if showSearch}
<div class="search-container">
<Search size={18} class="search-icon" />
<input
bind:this={searchInputElement}
type="text"
placeholder={searchPlaceholder}
value={searchInput}
oninput={handleSearchInput}
class="search-input"
/>
{#if searchInput}
<button onclick={clearSearch} class="clear-btn" aria-label="Suche löschen">
<X size={16} />
</button>
{/if}
</div>
{/if}
<!-- Filter toggle -->
{#if tags.length > 0 || subtitles.length > 0}