mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
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:
parent
9eb3f42483
commit
863f296733
23 changed files with 2347 additions and 787 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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] || '';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
961
apps/contacts/apps/web/src/lib/components/NewContactModal.svelte
Normal file
961
apps/contacts/apps/web/src/lib/components/NewContactModal.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
146
apps/contacts/apps/web/src/lib/stores/filter.svelte.ts
Normal file
146
apps/contacts/apps/web/src/lib/stores/filter.svelte.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as ExpandableToolbar } from './ExpandableToolbar.svelte';
|
||||
export type { ExpandableToolbarProps } from './types';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue