mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 10:26:41 +02:00
feat(contacts): add enhanced favorites page with multiple view modes
- Add quick favorites filter button on homepage with badge count - Create dedicated favorites page with hero header and stats cards - Implement three view modes for favorites: cards, list, alphabet - Cards: Large 120px avatars with gradient backgrounds, full contact details - List: 72px avatars with detail chips and hover actions - Alphabet: Grouped by letter with quick-jump navigation - Fix layout jump when favorites filter is active (exclude from FilterBar count) - Add tags management feature with CRUD operations - Reorganize import page to /data route - Add infinite scroll to contacts list - Add contact notes feature - Persist favorites view mode in localStorage 🤖 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
76f573fb08
commit
4e5d12aa53
20 changed files with 4127 additions and 642 deletions
|
|
@ -208,7 +208,7 @@ S3_BUCKET=contacts-photos
|
||||||
# Get credentials from https://console.cloud.google.com/apis/credentials
|
# Get credentials from https://console.cloud.google.com/apis/credentials
|
||||||
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||||
GOOGLE_CLIENT_SECRET=your-client-secret
|
GOOGLE_CLIENT_SECRET=your-client-secret
|
||||||
GOOGLE_REDIRECT_URI=http://localhost:5184/import?tab=google
|
GOOGLE_REDIRECT_URI=http://localhost:5184/data?tab=import&source=google
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Mobile (.env)
|
#### Mobile (.env)
|
||||||
|
|
|
||||||
|
|
@ -71,4 +71,43 @@ export class TagController {
|
||||||
await this.tagService.delete(id, user.userId);
|
await this.tagService.delete(id, user.userId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/contacts/:contactId')
|
||||||
|
async addToContact(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Param('id', ParseUUIDPipe) tagId: string,
|
||||||
|
@Param('contactId', ParseUUIDPipe) contactId: string
|
||||||
|
) {
|
||||||
|
// Verify tag belongs to user
|
||||||
|
const tag = await this.tagService.findById(tagId, user.userId);
|
||||||
|
if (!tag) {
|
||||||
|
throw new Error('Tag not found');
|
||||||
|
}
|
||||||
|
await this.tagService.addTagToContact(contactId, tagId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/contacts/:contactId')
|
||||||
|
async removeFromContact(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Param('id', ParseUUIDPipe) tagId: string,
|
||||||
|
@Param('contactId', ParseUUIDPipe) contactId: string
|
||||||
|
) {
|
||||||
|
// Verify tag belongs to user
|
||||||
|
const tag = await this.tagService.findById(tagId, user.userId);
|
||||||
|
if (!tag) {
|
||||||
|
throw new Error('Tag not found');
|
||||||
|
}
|
||||||
|
await this.tagService.removeTagFromContact(contactId, tagId);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('contact/:contactId')
|
||||||
|
async getTagsForContact(
|
||||||
|
@CurrentUser() user: CurrentUserData,
|
||||||
|
@Param('contactId', ParseUUIDPipe) contactId: string
|
||||||
|
) {
|
||||||
|
const tagIds = await this.tagService.getTagsForContact(contactId);
|
||||||
|
return { tagIds };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -210,29 +210,45 @@ export const groupsApi = {
|
||||||
|
|
||||||
// Tags API
|
// Tags API
|
||||||
export const tagsApi = {
|
export const tagsApi = {
|
||||||
async list() {
|
async list(): Promise<{ tags: ContactTag[] }> {
|
||||||
return fetchWithAuth('/tags');
|
return fetchWithAuth('/tags');
|
||||||
},
|
},
|
||||||
|
|
||||||
async create(data: { name: string; color?: string }) {
|
async create(data: { name: string; color?: string }): Promise<{ tag: ContactTag }> {
|
||||||
return fetchWithAuth('/tags', {
|
return fetchWithAuth('/tags', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(id: string, data: { name?: string; color?: string }) {
|
async update(id: string, data: { name?: string; color?: string }): Promise<{ tag: ContactTag }> {
|
||||||
return fetchWithAuth(`/tags/${id}`, {
|
return fetchWithAuth(`/tags/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id: string) {
|
async delete(id: string): Promise<{ success: boolean }> {
|
||||||
return fetchWithAuth(`/tags/${id}`, {
|
return fetchWithAuth(`/tags/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async addToContact(tagId: string, contactId: string): Promise<{ success: boolean }> {
|
||||||
|
return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeFromContact(tagId: string, contactId: string): Promise<{ success: boolean }> {
|
||||||
|
return fetchWithAuth(`/tags/${tagId}/contacts/${contactId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getForContact(contactId: string): Promise<{ tagIds: string[] }> {
|
||||||
|
return fetchWithAuth(`/tags/contact/${contactId}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Notes API
|
// Notes API
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { contactsApi, photoApi, type Contact } from '$lib/api/contacts';
|
import { contactsApi, photoApi, type Contact } from '$lib/api/contacts';
|
||||||
|
import ContactNotes from './ContactNotes.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
contactId: string;
|
contactId: string;
|
||||||
|
|
@ -848,6 +849,9 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Contact Notes (separate from contact.notes field) -->
|
||||||
|
<ContactNotes {contactId} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { _ } from 'svelte-i18n';
|
||||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||||
import { viewModeStore } from '$lib/stores/view-mode.svelte';
|
import { viewModeStore } from '$lib/stores/view-mode.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import ExportModal from '$lib/components/export/ExportModal.svelte';
|
|
||||||
import ViewModeToggle from '$lib/components/ViewModeToggle.svelte';
|
import ViewModeToggle from '$lib/components/ViewModeToggle.svelte';
|
||||||
import SortToggle, { type SortField } from '$lib/components/SortToggle.svelte';
|
import SortToggle, { type SortField } from '$lib/components/SortToggle.svelte';
|
||||||
import FilterBar, {
|
import FilterBar, {
|
||||||
|
|
@ -20,7 +19,11 @@
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let sortField = $state<SortField>('lastName');
|
let sortField = $state<SortField>('lastName');
|
||||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
let showExportModal = $state(false);
|
|
||||||
|
// Infinite scroll
|
||||||
|
let scrollContainer: HTMLDivElement;
|
||||||
|
let intersectionObserver: IntersectionObserver | null = null;
|
||||||
|
let loadMoreTrigger: HTMLDivElement;
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
let selectedGroupId = $state<string | null>(null);
|
let selectedGroupId = $state<string | null>(null);
|
||||||
|
|
@ -28,6 +31,9 @@
|
||||||
let birthdayFilter = $state<BirthdayFilter>('all');
|
let birthdayFilter = $state<BirthdayFilter>('all');
|
||||||
let selectedCompany = $state<string | null>(null);
|
let selectedCompany = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Count favorites for quick filter button
|
||||||
|
let favoritesCount = $derived(contactsStore.contacts.filter((c) => c.isFavorite).length);
|
||||||
|
|
||||||
// Batch selection state
|
// Batch selection state
|
||||||
let selectionMode = $state(false);
|
let selectionMode = $state(false);
|
||||||
let selectedIds = $state<Set<string>>(new Set());
|
let selectedIds = $state<Set<string>>(new Set());
|
||||||
|
|
@ -78,7 +84,9 @@
|
||||||
let result = [...contactsStore.contacts];
|
let result = [...contactsStore.contacts];
|
||||||
|
|
||||||
// Apply contact filter
|
// Apply contact filter
|
||||||
if (contactFilter === 'hasPhone') {
|
if (contactFilter === 'favorites') {
|
||||||
|
result = result.filter((c) => c.isFavorite);
|
||||||
|
} else if (contactFilter === 'hasPhone') {
|
||||||
result = result.filter((c) => c.phone || c.mobile);
|
result = result.filter((c) => c.phone || c.mobile);
|
||||||
} else if (contactFilter === 'hasEmail') {
|
} else if (contactFilter === 'hasEmail') {
|
||||||
result = result.filter((c) => c.email);
|
result = result.filter((c) => c.email);
|
||||||
|
|
@ -218,11 +226,51 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupInfiniteScroll() {
|
||||||
|
if (intersectionObserver) {
|
||||||
|
intersectionObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
intersectionObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (entry?.isIntersecting && contactsStore.hasMore && !contactsStore.loadingMore) {
|
||||||
|
contactsStore.loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: '200px',
|
||||||
|
threshold: 0.1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loadMoreTrigger) {
|
||||||
|
intersectionObserver.observe(loadMoreTrigger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Only load if not already loaded
|
// Only load if not already loaded
|
||||||
if (contactsStore.contacts.length === 0) {
|
if (contactsStore.contacts.length === 0) {
|
||||||
await contactsStore.loadContacts();
|
await contactsStore.loadContacts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup infinite scroll after DOM is ready
|
||||||
|
setupInfiniteScroll();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (intersectionObserver) {
|
||||||
|
intersectionObserver.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-setup observer when trigger element changes
|
||||||
|
$effect(() => {
|
||||||
|
if (loadMoreTrigger && intersectionObserver) {
|
||||||
|
intersectionObserver.disconnect();
|
||||||
|
intersectionObserver.observe(loadMoreTrigger);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -248,22 +296,6 @@
|
||||||
</svg>
|
</svg>
|
||||||
<span class="hidden sm:inline">{selectionMode ? 'Fertig' : 'Auswählen'}</span>
|
<span class="hidden sm:inline">{selectionMode ? 'Fertig' : 'Auswählen'}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (showExportModal = true)}
|
|
||||||
class="btn btn-secondary flex items-center gap-2"
|
|
||||||
title={$_('export.title')}
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="hidden sm:inline">{$_('export.button')}</span>
|
|
||||||
</button>
|
|
||||||
<a href="/contacts/new" class="btn btn-primary flex items-center gap-2">
|
<a href="/contacts/new" class="btn btn-primary flex items-center gap-2">
|
||||||
<span>+</span>
|
<span>+</span>
|
||||||
<span>{$_('contacts.new')}</span>
|
<span>{$_('contacts.new')}</span>
|
||||||
|
|
@ -372,6 +404,32 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Quick Favorites Filter -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="favorites-quick-btn"
|
||||||
|
class:active={contactFilter === 'favorites'}
|
||||||
|
onclick={() => (contactFilter = contactFilter === 'favorites' ? 'all' : 'favorites')}
|
||||||
|
title={contactFilter === 'favorites' ? 'Alle Kontakte anzeigen' : 'Nur Favoriten anzeigen'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
class:filled={contactFilter === 'favorites'}
|
||||||
|
fill={contactFilter === 'favorites' ? '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="favorites-count">{favoritesCount}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
<FilterBar
|
<FilterBar
|
||||||
contacts={contactsStore.contacts}
|
contacts={contactsStore.contacts}
|
||||||
{selectedGroupId}
|
{selectedGroupId}
|
||||||
|
|
@ -444,17 +502,26 @@
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Infinite scroll trigger & loading more indicator -->
|
||||||
|
{#if contactsStore.hasMore}
|
||||||
|
<div bind:this={loadMoreTrigger} class="load-more-trigger">
|
||||||
|
{#if contactsStore.loadingMore}
|
||||||
|
<div class="loading-more">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<span>{$_('common.loadingMore')}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Total count -->
|
<!-- Total count -->
|
||||||
<p class="text-sm text-muted-foreground text-center">
|
<p class="text-sm text-muted-foreground text-center">
|
||||||
{contactsStore.total}
|
{contactsStore.contacts.length} / {contactsStore.total}
|
||||||
{contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
|
{contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Export Modal -->
|
|
||||||
<ExportModal isOpen={showExportModal} onClose={() => (showExportModal = false)} />
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.batch-actions-bar {
|
.batch-actions-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -496,4 +563,86 @@
|
||||||
background: hsl(var(--color-error) / 0.15);
|
background: hsl(var(--color-error) / 0.15);
|
||||||
color: hsl(var(--color-error));
|
color: hsl(var(--color-error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Favorites Quick Filter Button */
|
||||||
|
.favorites-quick-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
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: 9999px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-quick-btn:hover {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-quick-btn.active {
|
||||||
|
color: #ef4444;
|
||||||
|
border-color: #ef4444 / 0.5;
|
||||||
|
background: hsl(0 84% 60% / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-quick-btn.active:hover {
|
||||||
|
background: hsl(0 84% 60% / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-count {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0 0.375rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-quick-btn.active .favorites-count {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Infinite scroll */
|
||||||
|
.load-more-trigger {
|
||||||
|
height: 1px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border: 2px solid hsl(var(--muted));
|
||||||
|
border-top-color: hsl(var(--primary));
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
598
apps/contacts/apps/web/src/lib/components/ContactNotes.svelte
Normal file
598
apps/contacts/apps/web/src/lib/components/ContactNotes.svelte
Normal file
|
|
@ -0,0 +1,598 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { notesApi, type ContactNote } from '$lib/api/contacts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
contactId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { contactId }: Props = $props();
|
||||||
|
|
||||||
|
let notes = $state<ContactNote[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
// New note state
|
||||||
|
let newNoteContent = $state('');
|
||||||
|
let addingNote = $state(false);
|
||||||
|
let showAddForm = $state(false);
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
let editingNoteId = $state<string | null>(null);
|
||||||
|
let editContent = $state('');
|
||||||
|
let savingEdit = $state(false);
|
||||||
|
|
||||||
|
const sortedNotes = $derived.by(() => {
|
||||||
|
// Pinned notes first, then by date
|
||||||
|
return [...notes].sort((a, b) => {
|
||||||
|
if (a.isPinned && !b.isPinned) return -1;
|
||||||
|
if (!a.isPinned && b.isPinned) return 1;
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadNotes() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const response = await notesApi.list(contactId);
|
||||||
|
notes = response.notes || [];
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : $_('messages.error');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddNote() {
|
||||||
|
if (!newNoteContent.trim()) return;
|
||||||
|
|
||||||
|
addingNote = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const response = await notesApi.create(contactId, {
|
||||||
|
content: newNoteContent.trim(),
|
||||||
|
});
|
||||||
|
notes = [...notes, response.note];
|
||||||
|
newNoteContent = '';
|
||||||
|
showAddForm = false;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : $_('messages.error');
|
||||||
|
} finally {
|
||||||
|
addingNote = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditing(note: ContactNote) {
|
||||||
|
editingNoteId = note.id;
|
||||||
|
editContent = note.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditing() {
|
||||||
|
editingNoteId = null;
|
||||||
|
editContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit(noteId: string) {
|
||||||
|
if (!editContent.trim()) return;
|
||||||
|
|
||||||
|
savingEdit = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const response = await notesApi.update(noteId, {
|
||||||
|
content: editContent.trim(),
|
||||||
|
});
|
||||||
|
notes = notes.map((n) => (n.id === noteId ? response.note : n));
|
||||||
|
editingNoteId = null;
|
||||||
|
editContent = '';
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : $_('messages.error');
|
||||||
|
} finally {
|
||||||
|
savingEdit = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(noteId: string) {
|
||||||
|
if (!confirm($_('notes.confirmDelete'))) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await notesApi.delete(noteId);
|
||||||
|
notes = notes.filter((n) => n.id !== noteId);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : $_('messages.error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTogglePin(noteId: string) {
|
||||||
|
try {
|
||||||
|
const response = await notesApi.togglePin(noteId);
|
||||||
|
notes = notes.map((n) => (n.id === noteId ? response.note : n));
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : $_('messages.error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays === 0) {
|
||||||
|
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
} else if (diffDays === 1) {
|
||||||
|
return $_('notes.yesterday');
|
||||||
|
} else if (diffDays < 7) {
|
||||||
|
return date.toLocaleDateString('de-DE', { weekday: 'short' });
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadNotes);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="notes-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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="section-title">{$_('notes.title')}</h3>
|
||||||
|
<button
|
||||||
|
onclick={() => (showAddForm = !showAddForm)}
|
||||||
|
class="add-note-btn"
|
||||||
|
aria-label={$_('notes.add')}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error-message">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add Note Form -->
|
||||||
|
{#if showAddForm}
|
||||||
|
<div class="add-note-form">
|
||||||
|
<textarea
|
||||||
|
bind:value={newNoteContent}
|
||||||
|
placeholder={$_('notes.placeholder')}
|
||||||
|
class="note-input"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
showAddForm = false;
|
||||||
|
newNoteContent = '';
|
||||||
|
}}
|
||||||
|
class="btn-cancel"
|
||||||
|
disabled={addingNote}
|
||||||
|
>
|
||||||
|
{$_('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleAddNote}
|
||||||
|
class="btn-save"
|
||||||
|
disabled={addingNote || !newNoteContent.trim()}
|
||||||
|
>
|
||||||
|
{#if addingNote}
|
||||||
|
<span class="spinner"></span>
|
||||||
|
{/if}
|
||||||
|
{$_('notes.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Notes List -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">
|
||||||
|
<span class="spinner"></span>
|
||||||
|
</div>
|
||||||
|
{:else if notes.length === 0 && !showAddForm}
|
||||||
|
<div class="empty-notes">
|
||||||
|
<p>{$_('notes.empty')}</p>
|
||||||
|
<button onclick={() => (showAddForm = true)} class="btn-add-first">
|
||||||
|
{$_('notes.addFirst')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="notes-list">
|
||||||
|
{#each sortedNotes as note (note.id)}
|
||||||
|
<div class="note-item" class:pinned={note.isPinned}>
|
||||||
|
{#if editingNoteId === note.id}
|
||||||
|
<!-- Edit Mode -->
|
||||||
|
<textarea bind:value={editContent} class="note-input edit-input" rows="3"></textarea>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button onclick={cancelEditing} class="btn-cancel" disabled={savingEdit}>
|
||||||
|
{$_('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleSaveEdit(note.id)}
|
||||||
|
class="btn-save"
|
||||||
|
disabled={savingEdit || !editContent.trim()}
|
||||||
|
>
|
||||||
|
{#if savingEdit}
|
||||||
|
<span class="spinner"></span>
|
||||||
|
{/if}
|
||||||
|
{$_('actions.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- View Mode -->
|
||||||
|
<div class="note-content">
|
||||||
|
{#if note.isPinned}
|
||||||
|
<span class="pin-badge">
|
||||||
|
<svg fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<p class="note-text">{note.content}</p>
|
||||||
|
<span class="note-date">{formatDate(note.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="note-actions">
|
||||||
|
<button
|
||||||
|
onclick={() => handleTogglePin(note.id)}
|
||||||
|
class="note-action"
|
||||||
|
class:active={note.isPinned}
|
||||||
|
aria-label={note.isPinned ? $_('notes.unpin') : $_('notes.pin')}
|
||||||
|
title={note.isPinned ? $_('notes.unpin') : $_('notes.pin')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill={note.isPinned ? 'currentColor' : 'none'}
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => startEditing(note)}
|
||||||
|
class="note-action"
|
||||||
|
aria-label={$_('actions.edit')}
|
||||||
|
title={$_('actions.edit')}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(note.id)}
|
||||||
|
class="note-action delete"
|
||||||
|
aria-label={$_('actions.delete')}
|
||||||
|
title={$_('actions.delete')}
|
||||||
|
>
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.notes-section {
|
||||||
|
background: hsl(var(--color-surface));
|
||||||
|
border: 1px solid hsl(var(--color-border));
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-note-btn {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: hsl(var(--color-primary));
|
||||||
|
color: hsl(var(--color-primary-foreground));
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-note-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-note-btn svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: hsl(var(--color-error) / 0.1);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: hsl(var(--color-error));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add Note Form */
|
||||||
|
.add-note-form {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
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;
|
||||||
|
resize: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: hsl(var(--color-primary));
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-input {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel,
|
||||||
|
.btn-save {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: hsl(var(--color-muted));
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover:not(:disabled) {
|
||||||
|
background: hsl(var(--color-surface-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
background: hsl(var(--color-primary));
|
||||||
|
color: hsl(var(--color-primary-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 2px 8px hsl(var(--color-primary) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:disabled,
|
||||||
|
.btn-save:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading & Empty */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border: 2px solid hsl(var(--color-muted));
|
||||||
|
border-top-color: hsl(var(--color-primary));
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-notes {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-notes p {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-first {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: hsl(var(--color-primary) / 0.1);
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
border: none;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-first:hover {
|
||||||
|
background: hsl(var(--color-primary) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notes List */
|
||||||
|
.notes-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: hsl(var(--color-muted) / 0.3);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item:hover {
|
||||||
|
background: hsl(var(--color-muted) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item.pinned {
|
||||||
|
background: hsl(var(--color-primary) / 0.08);
|
||||||
|
border: 1px solid hsl(var(--color-primary) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-badge svg {
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-date {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item:hover .note-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--color-muted-foreground));
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-action:hover {
|
||||||
|
background: hsl(var(--color-muted));
|
||||||
|
color: hsl(var(--color-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-action.active {
|
||||||
|
color: hsl(var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-action.delete:hover {
|
||||||
|
background: hsl(var(--color-error) / 0.1);
|
||||||
|
color: hsl(var(--color-error));
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-action svg {
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -45,11 +45,11 @@
|
||||||
return Array.from(companySet).sort((a, b) => a.localeCompare(b, 'de'));
|
return Array.from(companySet).sort((a, b) => a.localeCompare(b, 'de'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Count active filters
|
// Count active filters (excluding favorites since it has its own quick button)
|
||||||
let activeFilterCount = $derived.by(() => {
|
let activeFilterCount = $derived.by(() => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
if (selectedGroupId) count++;
|
if (selectedGroupId) count++;
|
||||||
if (contactFilter !== 'all') count++;
|
if (contactFilter !== 'all' && contactFilter !== 'favorites') count++;
|
||||||
if (birthdayFilter !== 'all') count++;
|
if (birthdayFilter !== 'all') count++;
|
||||||
if (selectedCompany) count++;
|
if (selectedCompany) count++;
|
||||||
return count;
|
return count;
|
||||||
|
|
@ -68,7 +68,10 @@
|
||||||
|
|
||||||
function clearAllFilters() {
|
function clearAllFilters() {
|
||||||
onGroupChange(null);
|
onGroupChange(null);
|
||||||
onContactFilterChange('all');
|
// Keep favorites filter if active (controlled by separate quick button)
|
||||||
|
if (contactFilter !== 'favorites') {
|
||||||
|
onContactFilterChange('all');
|
||||||
|
}
|
||||||
onBirthdayFilterChange('all');
|
onBirthdayFilterChange('all');
|
||||||
onCompanyChange(null);
|
onCompanyChange(null);
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +123,7 @@
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if contactFilter !== 'all'}
|
{#if contactFilter !== 'all' && contactFilter !== 'favorites'}
|
||||||
<button type="button" class="filter-pill" onclick={() => onContactFilterChange('all')}>
|
<button type="button" class="filter-pill" onclick={() => onContactFilterChange('all')}>
|
||||||
{$_(`filters.contact.${contactFilter}`)}
|
{$_(`filters.contact.${contactFilter}`)}
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,494 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import type { Contact } from '$lib/api/contacts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
contacts: Contact[];
|
||||||
|
onContactClick: (id: string) => void;
|
||||||
|
onToggleFavorite: (e: MouseEvent, id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { contacts, onContactClick, onToggleFavorite }: Props = $props();
|
||||||
|
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||||
|
|
||||||
|
function getInitials(contact: Contact) {
|
||||||
|
const first = contact.firstName?.[0] || '';
|
||||||
|
const last = contact.lastName?.[0] || '';
|
||||||
|
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(contact: Contact) {
|
||||||
|
if (contact.displayName) return contact.displayName;
|
||||||
|
if (contact.firstName || contact.lastName) {
|
||||||
|
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
return contact.email || 'Unbekannt';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstLetter(contact: Contact): string {
|
||||||
|
const name =
|
||||||
|
contact.lastName || contact.firstName || contact.displayName || contact.email || '';
|
||||||
|
const letter = name[0]?.toUpperCase() || '#';
|
||||||
|
return /[A-Z]/.test(letter) ? letter : '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPhone(phone: string | null | undefined) {
|
||||||
|
if (!phone) return null;
|
||||||
|
return phone.replace(/\s/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group contacts by first letter
|
||||||
|
let groupedContacts = $derived.by(() => {
|
||||||
|
const groups: Record<string, Contact[]> = {};
|
||||||
|
|
||||||
|
// Sort contacts by last name first
|
||||||
|
const sorted = [...contacts].sort((a, b) => {
|
||||||
|
const aName = (a.lastName || a.firstName || a.displayName || a.email || '').toLowerCase();
|
||||||
|
const bName = (b.lastName || b.firstName || b.displayName || b.email || '').toLowerCase();
|
||||||
|
return aName.localeCompare(bName, 'de');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const contact of sorted) {
|
||||||
|
const letter = getFirstLetter(contact);
|
||||||
|
if (!groups[letter]) {
|
||||||
|
groups[letter] = [];
|
||||||
|
}
|
||||||
|
groups[letter].push(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
let availableLetters = $derived(Object.keys(groupedContacts).sort());
|
||||||
|
|
||||||
|
function scrollToLetter(letter: string) {
|
||||||
|
const element = document.getElementById(`fav-section-${letter}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="alphabet-view">
|
||||||
|
<!-- Contacts grouped by letter -->
|
||||||
|
<div class="alphabet-sections">
|
||||||
|
{#each availableLetters as letter}
|
||||||
|
<div id="fav-section-{letter}" class="alphabet-section">
|
||||||
|
<!-- Section Header -->
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-letter">{letter}</span>
|
||||||
|
<span class="section-count">{groupedContacts[letter].length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contacts in this section -->
|
||||||
|
<div class="section-contacts">
|
||||||
|
{#each groupedContacts[letter] as contact (contact.id)}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={() => onContactClick(contact.id)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
|
||||||
|
class="alphabet-card"
|
||||||
|
>
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div class="card-avatar">
|
||||||
|
{#if contact.photoUrl}
|
||||||
|
<img
|
||||||
|
src={contact.photoUrl}
|
||||||
|
alt={getDisplayName(contact)}
|
||||||
|
class="w-full h-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{getInitials(contact)}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Info -->
|
||||||
|
<div class="card-content">
|
||||||
|
<h3 class="card-name">{getDisplayName(contact)}</h3>
|
||||||
|
<div class="card-meta">
|
||||||
|
{#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>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if contact.phone || contact.mobile || contact.email}
|
||||||
|
<div class="card-chips">
|
||||||
|
{#if contact.phone || contact.mobile}
|
||||||
|
<span class="chip">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{contact.mobile || contact.phone}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if contact.email}
|
||||||
|
<span class="chip">
|
||||||
|
<svg class="w-3 h-3" 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>
|
||||||
|
{contact.email}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="card-actions">
|
||||||
|
{#if contact.phone || contact.mobile}
|
||||||
|
<a
|
||||||
|
href="tel:{formatPhone(contact.mobile || contact.phone)}"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
class="action-btn action-call"
|
||||||
|
title="Anrufen"
|
||||||
|
>
|
||||||
|
<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="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if contact.email}
|
||||||
|
<a
|
||||||
|
href="mailto:{contact.email}"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
class="action-btn action-email"
|
||||||
|
title="E-Mail senden"
|
||||||
|
>
|
||||||
|
<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="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>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={(e) => onToggleFavorite(e, contact.id)}
|
||||||
|
class="action-btn action-heart"
|
||||||
|
title="Aus Favoriten entfernen"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" 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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alphabet Quick Jump -->
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.alphabet-view {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alphabet-sections {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alphabet-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 80px;
|
||||||
|
z-index: 10;
|
||||||
|
background: linear-gradient(135deg, hsl(0 84% 60% / 0.1), hsl(0 84% 60% / 0.05));
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid hsl(0 84% 60% / 0.2);
|
||||||
|
border-radius: 9999px;
|
||||||
|
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-letter {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ef4444;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-contacts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alphabet-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alphabet-card:hover {
|
||||||
|
border-color: hsl(var(--primary) / 0.4);
|
||||||
|
background: hsl(var(--accent));
|
||||||
|
box-shadow: 0 4px 12px -2px hsl(var(--foreground) / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-avatar {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
min-width: 64px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.375rem;
|
||||||
|
box-shadow: 0 4px 12px -2px hsl(var(--primary) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.1875rem 0.5rem;
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alphabet-card:hover .card-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-call {
|
||||||
|
background: hsl(142 76% 36% / 0.1);
|
||||||
|
color: hsl(142 76% 36%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-call:hover {
|
||||||
|
background: hsl(142 76% 36%);
|
||||||
|
color: white;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-email {
|
||||||
|
background: hsl(var(--primary) / 0.1);
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-email:hover {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-heart {
|
||||||
|
background: hsl(0 84% 60% / 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-heart:hover {
|
||||||
|
background: hsl(0 84% 60% / 0.2);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alphabet Navigation */
|
||||||
|
.alphabet-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 80px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
height: fit-content;
|
||||||
|
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, 0.75rem);
|
||||||
|
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alphabet-nav-btn {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alphabet-nav-btn.active {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.alphabet-nav-btn.active:hover {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alphabet-nav-btn.disabled {
|
||||||
|
color: hsl(var(--muted-foreground) / 0.3);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.alphabet-view {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alphabet-nav-btn {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alphabet-sections {
|
||||||
|
padding-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-chips {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,363 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import type { Contact } from '$lib/api/contacts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
contacts: Contact[];
|
||||||
|
onContactClick: (id: string) => void;
|
||||||
|
onToggleFavorite: (e: MouseEvent, id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { contacts, onContactClick, onToggleFavorite }: Props = $props();
|
||||||
|
|
||||||
|
function getInitials(contact: Contact) {
|
||||||
|
const first = contact.firstName?.[0] || '';
|
||||||
|
const last = contact.lastName?.[0] || '';
|
||||||
|
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(contact: Contact) {
|
||||||
|
if (contact.displayName) return contact.displayName;
|
||||||
|
if (contact.firstName || contact.lastName) {
|
||||||
|
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
return contact.email || 'Unbekannt';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a consistent gradient based on contact name
|
||||||
|
function getGradient(contact: Contact) {
|
||||||
|
const name = getDisplayName(contact);
|
||||||
|
const hash = name.split('').reduce((acc, char) => char.charCodeAt(0) + acc, 0);
|
||||||
|
const gradients = [
|
||||||
|
'from-rose-500 to-pink-600',
|
||||||
|
'from-violet-500 to-purple-600',
|
||||||
|
'from-blue-500 to-indigo-600',
|
||||||
|
'from-cyan-500 to-teal-600',
|
||||||
|
'from-emerald-500 to-green-600',
|
||||||
|
'from-amber-500 to-orange-600',
|
||||||
|
'from-red-500 to-rose-600',
|
||||||
|
'from-fuchsia-500 to-pink-600',
|
||||||
|
];
|
||||||
|
return gradients[hash % gradients.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPhone(phone: string | null | undefined) {
|
||||||
|
if (!phone) return null;
|
||||||
|
return phone.replace(/\s/g, '');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="favorites-grid">
|
||||||
|
{#each contacts as contact (contact.id)}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={() => onContactClick(contact.id)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
|
||||||
|
class="favorite-card"
|
||||||
|
>
|
||||||
|
<!-- Decorative background gradient -->
|
||||||
|
<div class="card-bg bg-gradient-to-br {getGradient(contact)}"></div>
|
||||||
|
|
||||||
|
<!-- Favorite badge -->
|
||||||
|
<button
|
||||||
|
onclick={(e) => onToggleFavorite(e, contact.id)}
|
||||||
|
class="favorite-badge"
|
||||||
|
title="Aus Favoriten entfernen"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" 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>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Large Avatar -->
|
||||||
|
<div class="card-avatar bg-gradient-to-br {getGradient(contact)}">
|
||||||
|
{#if contact.photoUrl}
|
||||||
|
<img
|
||||||
|
src={contact.photoUrl}
|
||||||
|
alt={getDisplayName(contact)}
|
||||||
|
class="w-full h-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="text-white font-bold text-3xl">{getInitials(contact)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Info -->
|
||||||
|
<div class="card-content">
|
||||||
|
<h3 class="card-name">{getDisplayName(contact)}</h3>
|
||||||
|
{#if contact.jobTitle}
|
||||||
|
<p class="card-job">{contact.jobTitle}</p>
|
||||||
|
{/if}
|
||||||
|
{#if contact.company}
|
||||||
|
<p class="card-company">{contact.company}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Contact Details -->
|
||||||
|
<div class="card-details">
|
||||||
|
{#if contact.email}
|
||||||
|
<div class="detail-row">
|
||||||
|
<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="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>
|
||||||
|
<span class="detail-text">{contact.email}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if contact.phone || contact.mobile}
|
||||||
|
<div class="detail-row">
|
||||||
|
<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="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="detail-text">{contact.mobile || contact.phone}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if contact.birthday}
|
||||||
|
<div class="detail-row">
|
||||||
|
<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="M21 15.546c-.523 0-1.046.151-1.5.454a2.704 2.704 0 01-3 0 2.704 2.704 0 00-3 0 2.704 2.704 0 01-3 0 2.704 2.704 0 00-3 0 2.704 2.704 0 01-3 0 2.701 2.701 0 00-1.5-.454M9 6v2m3-2v2m3-2v2M9 3h.01M12 3h.01M15 3h.01M21 21v-7a2 2 0 00-2-2H5a2 2 0 00-2 2v7h18zm-3-9v-2a2 2 0 00-2-2H8a2 2 0 00-2 2v2h12z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="detail-text"
|
||||||
|
>{new Date(contact.birthday).toLocaleDateString('de-DE', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
})}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="card-actions">
|
||||||
|
{#if contact.phone || contact.mobile}
|
||||||
|
<a
|
||||||
|
href="tel:{formatPhone(contact.mobile || contact.phone)}"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
class="action-btn action-call"
|
||||||
|
title="Anrufen"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if contact.email}
|
||||||
|
<a
|
||||||
|
href="mailto:{contact.email}"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
class="action-btn action-email"
|
||||||
|
title="E-Mail senden"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" 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>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.favorites-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.favorites-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.favorites-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem 1.5rem 1.5rem;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-card:hover {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 40px -12px hsl(var(--foreground) / 0.15),
|
||||||
|
0 4px 12px -2px hsl(var(--foreground) / 0.1);
|
||||||
|
border-color: hsl(var(--primary) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 80px;
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
background: hsl(var(--background) / 0.9);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
color: #ef4444;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-badge:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
background: hsl(0 84% 60% / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-avatar {
|
||||||
|
position: relative;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 24px -4px hsl(var(--foreground) / 0.2),
|
||||||
|
0 0 0 4px hsl(var(--background));
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-job {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-company {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--muted-foreground) / 0.8);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid hsl(var(--border) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid hsl(var(--border) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-call {
|
||||||
|
background: hsl(142 76% 36% / 0.1);
|
||||||
|
color: hsl(142 76% 36%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-call:hover {
|
||||||
|
background: hsl(142 76% 36%);
|
||||||
|
color: white;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-email {
|
||||||
|
background: hsl(var(--primary) / 0.1);
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-email:hover {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,324 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import type { Contact } from '$lib/api/contacts';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
contacts: Contact[];
|
||||||
|
onContactClick: (id: string) => void;
|
||||||
|
onToggleFavorite: (e: MouseEvent, id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { contacts, onContactClick, onToggleFavorite }: Props = $props();
|
||||||
|
|
||||||
|
function getInitials(contact: Contact) {
|
||||||
|
const first = contact.firstName?.[0] || '';
|
||||||
|
const last = contact.lastName?.[0] || '';
|
||||||
|
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayName(contact: Contact) {
|
||||||
|
if (contact.displayName) return contact.displayName;
|
||||||
|
if (contact.firstName || contact.lastName) {
|
||||||
|
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
return contact.email || 'Unbekannt';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPhone(phone: string | null | undefined) {
|
||||||
|
if (!phone) return null;
|
||||||
|
return phone.replace(/\s/g, '');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="favorites-list">
|
||||||
|
{#each contacts as contact (contact.id)}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={() => onContactClick(contact.id)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
|
||||||
|
class="favorite-row"
|
||||||
|
>
|
||||||
|
<!-- Avatar -->
|
||||||
|
<div class="row-avatar">
|
||||||
|
{#if contact.photoUrl}
|
||||||
|
<img
|
||||||
|
src={contact.photoUrl}
|
||||||
|
alt={getDisplayName(contact)}
|
||||||
|
class="w-full h-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{getInitials(contact)}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Info -->
|
||||||
|
<div class="row-content">
|
||||||
|
<div class="row-main">
|
||||||
|
<h3 class="row-name">{getDisplayName(contact)}</h3>
|
||||||
|
{#if contact.jobTitle || contact.company}
|
||||||
|
<p class="row-subtitle">
|
||||||
|
{[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Details -->
|
||||||
|
<div class="row-details">
|
||||||
|
{#if contact.email}
|
||||||
|
<div class="detail-chip">
|
||||||
|
<svg class="w-3.5 h-3.5" 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>
|
||||||
|
<span>{contact.email}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if contact.phone || contact.mobile}
|
||||||
|
<div class="detail-chip">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{contact.mobile || contact.phone}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if contact.birthday}
|
||||||
|
<div class="detail-chip">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
>{new Date(contact.birthday).toLocaleDateString('de-DE', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
})}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="row-actions">
|
||||||
|
{#if contact.phone || contact.mobile}
|
||||||
|
<a
|
||||||
|
href="tel:{formatPhone(contact.mobile || contact.phone)}"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
class="action-btn action-call"
|
||||||
|
title="Anrufen"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if contact.email}
|
||||||
|
<a
|
||||||
|
href="mailto:{contact.email}"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
class="action-btn action-email"
|
||||||
|
title="E-Mail senden"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" 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>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={(e) => onToggleFavorite(e, contact.id)}
|
||||||
|
class="action-btn action-heart"
|
||||||
|
title="Aus Favoriten entfernen"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" 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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.favorites-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-row:hover {
|
||||||
|
border-color: hsl(var(--primary) / 0.4);
|
||||||
|
background: hsl(var(--accent));
|
||||||
|
transform: translateX(4px);
|
||||||
|
box-shadow: 0 4px 12px -2px hsl(var(--foreground) / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-avatar {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
min-width: 72px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
box-shadow: 0 4px 12px -2px hsl(var(--primary) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-main {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-subtitle {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-details {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-row:hover .row-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-call {
|
||||||
|
background: hsl(142 76% 36% / 0.1);
|
||||||
|
color: hsl(142 76% 36%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-call:hover {
|
||||||
|
background: hsl(142 76% 36%);
|
||||||
|
color: white;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-email {
|
||||||
|
background: hsl(var(--primary) / 0.1);
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-email:hover {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-heart {
|
||||||
|
background: hsl(0 84% 60% / 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-heart:hover {
|
||||||
|
background: hsl(0 84% 60% / 0.2);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.row-actions {
|
||||||
|
opacity: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
min-width: 56px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-details {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
try {
|
try {
|
||||||
await googleApi.handleCallback(code);
|
await googleApi.handleCallback(code);
|
||||||
// Remove code from URL
|
// Remove code from URL
|
||||||
goto('/import?tab=google', { replaceState: true });
|
goto('/data?tab=import&source=google', { replaceState: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to connect';
|
error = e instanceof Error ? e.message : 'Failed to connect';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"cancel": "Abbrechen"
|
"cancel": "Abbrechen",
|
||||||
|
"loadingMore": "Lade weitere..."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"contacts": "Kontakte",
|
"contacts": "Kontakte",
|
||||||
"groups": "Gruppen",
|
"groups": "Gruppen",
|
||||||
|
"tags": "Tags",
|
||||||
"favorites": "Favoriten",
|
"favorites": "Favoriten",
|
||||||
"archive": "Archiv",
|
"archive": "Archiv",
|
||||||
"search": "Suche",
|
"search": "Suche",
|
||||||
|
|
@ -143,6 +145,7 @@
|
||||||
"contactInfo": "Kontaktinfo",
|
"contactInfo": "Kontaktinfo",
|
||||||
"contact": {
|
"contact": {
|
||||||
"all": "Alle Kontakte",
|
"all": "Alle Kontakte",
|
||||||
|
"favorites": "Favoriten",
|
||||||
"hasPhone": "Mit Telefon",
|
"hasPhone": "Mit Telefon",
|
||||||
"hasEmail": "Mit E-Mail",
|
"hasEmail": "Mit E-Mail",
|
||||||
"incomplete": "Unvollständig"
|
"incomplete": "Unvollständig"
|
||||||
|
|
@ -166,5 +169,34 @@
|
||||||
"includeArchived": "Archivierte Kontakte einschließen",
|
"includeArchived": "Archivierte Kontakte einschließen",
|
||||||
"exporting": "Exportiere...",
|
"exporting": "Exportiere...",
|
||||||
"success": "Export erfolgreich"
|
"success": "Export erfolgreich"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"title": "Notizen",
|
||||||
|
"add": "Notiz hinzufügen",
|
||||||
|
"addFirst": "Erste Notiz hinzufügen",
|
||||||
|
"empty": "Noch keine Notizen",
|
||||||
|
"placeholder": "Schreibe eine Notiz...",
|
||||||
|
"confirmDelete": "Diese Notiz löschen?",
|
||||||
|
"pin": "Notiz anheften",
|
||||||
|
"unpin": "Nicht mehr anheften",
|
||||||
|
"yesterday": "Gestern"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"title": "Tags",
|
||||||
|
"new": "Neuer Tag",
|
||||||
|
"edit": "Tag bearbeiten",
|
||||||
|
"noTags": "Noch keine Tags",
|
||||||
|
"createFirst": "Erstelle deinen ersten Tag um Kontakte zu organisieren",
|
||||||
|
"search": "Tags durchsuchen...",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Tag-Name eingeben",
|
||||||
|
"color": "Farbe",
|
||||||
|
"preview": "Vorschau",
|
||||||
|
"contactCount": "{count} Kontakte",
|
||||||
|
"confirmDelete": "Möchtest du \"{name}\" wirklich löschen?",
|
||||||
|
"noResults": "Keine Tags gefunden",
|
||||||
|
"noResultsFor": "Keine Ergebnisse für \"{query}\"",
|
||||||
|
"tagSingular": "Tag",
|
||||||
|
"tagPlural": "Tags"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel",
|
||||||
|
"loadingMore": "Loading more..."
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
"groups": "Groups",
|
"groups": "Groups",
|
||||||
|
"tags": "Tags",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
|
|
@ -143,6 +145,7 @@
|
||||||
"contactInfo": "Contact info",
|
"contactInfo": "Contact info",
|
||||||
"contact": {
|
"contact": {
|
||||||
"all": "All contacts",
|
"all": "All contacts",
|
||||||
|
"favorites": "Favorites",
|
||||||
"hasPhone": "With phone",
|
"hasPhone": "With phone",
|
||||||
"hasEmail": "With email",
|
"hasEmail": "With email",
|
||||||
"incomplete": "Incomplete"
|
"incomplete": "Incomplete"
|
||||||
|
|
@ -166,5 +169,34 @@
|
||||||
"includeArchived": "Include archived contacts",
|
"includeArchived": "Include archived contacts",
|
||||||
"exporting": "Exporting...",
|
"exporting": "Exporting...",
|
||||||
"success": "Export successful"
|
"success": "Export successful"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"title": "Notes",
|
||||||
|
"add": "Add Note",
|
||||||
|
"addFirst": "Add your first note",
|
||||||
|
"empty": "No notes yet",
|
||||||
|
"placeholder": "Write a note...",
|
||||||
|
"confirmDelete": "Delete this note?",
|
||||||
|
"pin": "Pin note",
|
||||||
|
"unpin": "Unpin note",
|
||||||
|
"yesterday": "Yesterday"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"title": "Tags",
|
||||||
|
"new": "New Tag",
|
||||||
|
"edit": "Edit Tag",
|
||||||
|
"noTags": "No tags yet",
|
||||||
|
"createFirst": "Create your first tag to organize contacts",
|
||||||
|
"search": "Search tags...",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Enter tag name",
|
||||||
|
"color": "Color",
|
||||||
|
"preview": "Preview",
|
||||||
|
"contactCount": "{count} contacts",
|
||||||
|
"confirmDelete": "Are you sure you want to delete \"{name}\"?",
|
||||||
|
"noResults": "No tags found",
|
||||||
|
"noResultsFor": "No results for \"{query}\"",
|
||||||
|
"tagSingular": "Tag",
|
||||||
|
"tagPlural": "Tags"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,19 @@
|
||||||
import { contactsApi } from '$lib/api/contacts';
|
import { contactsApi } from '$lib/api/contacts';
|
||||||
import type { Contact, ContactFilters } from '$lib/api/contacts';
|
import type { Contact, ContactFilters } from '$lib/api/contacts';
|
||||||
|
|
||||||
|
// Default page size for pagination
|
||||||
|
const DEFAULT_PAGE_SIZE = 50;
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let contacts = $state<Contact[]>([]);
|
let contacts = $state<Contact[]>([]);
|
||||||
let selectedContact = $state<Contact | null>(null);
|
let selectedContact = $state<Contact | null>(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
let loadingMore = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let total = $state(0);
|
let total = $state(0);
|
||||||
let filters = $state<ContactFilters>({});
|
let filters = $state<ContactFilters>({});
|
||||||
|
let hasMore = $state(true);
|
||||||
|
let currentOffset = $state(0);
|
||||||
|
|
||||||
export const contactsStore = {
|
export const contactsStore = {
|
||||||
// Getters
|
// Getters
|
||||||
|
|
@ -24,6 +30,9 @@ export const contactsStore = {
|
||||||
get loading() {
|
get loading() {
|
||||||
return loading;
|
return loading;
|
||||||
},
|
},
|
||||||
|
get loadingMore() {
|
||||||
|
return loadingMore;
|
||||||
|
},
|
||||||
get error() {
|
get error() {
|
||||||
return error;
|
return error;
|
||||||
},
|
},
|
||||||
|
|
@ -33,9 +42,12 @@ export const contactsStore = {
|
||||||
get filters() {
|
get filters() {
|
||||||
return filters;
|
return filters;
|
||||||
},
|
},
|
||||||
|
get hasMore() {
|
||||||
|
return hasMore;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load contacts with optional filters
|
* Load contacts with optional filters (resets to first page)
|
||||||
*/
|
*/
|
||||||
async loadContacts(newFilters?: ContactFilters) {
|
async loadContacts(newFilters?: ContactFilters) {
|
||||||
if (newFilters) {
|
if (newFilters) {
|
||||||
|
|
@ -44,11 +56,18 @@ export const contactsStore = {
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
currentOffset = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await contactsApi.list(filters);
|
const result = await contactsApi.list({
|
||||||
|
...filters,
|
||||||
|
limit: DEFAULT_PAGE_SIZE,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
contacts = result.contacts;
|
contacts = result.contacts;
|
||||||
total = result.total;
|
total = result.total;
|
||||||
|
hasMore = contacts.length < total;
|
||||||
|
currentOffset = contacts.length;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to load contacts';
|
error = e instanceof Error ? e.message : 'Failed to load contacts';
|
||||||
console.error('Failed to load contacts:', e);
|
console.error('Failed to load contacts:', e);
|
||||||
|
|
@ -57,6 +76,35 @@ export const contactsStore = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load more contacts (infinite scroll)
|
||||||
|
*/
|
||||||
|
async loadMore() {
|
||||||
|
if (loadingMore || !hasMore) return;
|
||||||
|
|
||||||
|
loadingMore = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await contactsApi.list({
|
||||||
|
...filters,
|
||||||
|
limit: DEFAULT_PAGE_SIZE,
|
||||||
|
offset: currentOffset,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newContacts = result.contacts;
|
||||||
|
contacts = [...contacts, ...newContacts];
|
||||||
|
total = result.total;
|
||||||
|
currentOffset += newContacts.length;
|
||||||
|
hasMore = contacts.length < total;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load more contacts';
|
||||||
|
console.error('Failed to load more contacts:', e);
|
||||||
|
} finally {
|
||||||
|
loadingMore = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a single contact by ID
|
* Load a single contact by ID
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@
|
||||||
const navItems: PillNavItem[] = [
|
const navItems: PillNavItem[] = [
|
||||||
{ href: '/', label: 'Kontakte', icon: 'users' },
|
{ href: '/', label: 'Kontakte', icon: 'users' },
|
||||||
{ href: '/groups', label: 'Gruppen', icon: 'folder' },
|
{ href: '/groups', label: 'Gruppen', icon: 'folder' },
|
||||||
|
{ href: '/tags', label: 'Tags', icon: 'tag' },
|
||||||
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
|
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
|
||||||
{ href: '/archive', label: 'Archiv', icon: 'archive' },
|
{ href: '/archive', label: 'Archiv', icon: 'archive' },
|
||||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||||
|
|
|
||||||
635
apps/contacts/apps/web/src/routes/(app)/data/+page.svelte
Normal file
635
apps/contacts/apps/web/src/routes/(app)/data/+page.svelte
Normal file
|
|
@ -0,0 +1,635 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import FileUploader from '$lib/components/import/FileUploader.svelte';
|
||||||
|
import ImportPreview from '$lib/components/import/ImportPreview.svelte';
|
||||||
|
import GoogleImport from '$lib/components/import/GoogleImport.svelte';
|
||||||
|
import { importApi, type ImportPreviewResponse, type DuplicateAction } from '$lib/api/import';
|
||||||
|
import { exportApi, type ExportFormat } from '$lib/api/export';
|
||||||
|
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||||
|
import { groupsStore } from '$lib/stores/groups.svelte';
|
||||||
|
import '$lib/i18n';
|
||||||
|
|
||||||
|
type Tab = 'import' | 'export';
|
||||||
|
type ImportSource = 'file' | 'google';
|
||||||
|
type ImportStep = 'upload' | 'preview' | 'result';
|
||||||
|
|
||||||
|
// Get initial tab from URL
|
||||||
|
let activeTab = $state<Tab>(($page.url.searchParams.get('tab') as Tab) || 'import');
|
||||||
|
let importSource = $state<ImportSource>(
|
||||||
|
($page.url.searchParams.get('source') as ImportSource) || 'file'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import state
|
||||||
|
let importStep = $state<ImportStep>('upload');
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let isImporting = $state(false);
|
||||||
|
let importError = $state<string | null>(null);
|
||||||
|
let selectedFile = $state<File | null>(null);
|
||||||
|
let preview = $state<ImportPreviewResponse | null>(null);
|
||||||
|
let importResult = $state<{
|
||||||
|
imported: number;
|
||||||
|
skipped: number;
|
||||||
|
merged: number;
|
||||||
|
errors: { index: number; contactName: string; error: string }[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Export state
|
||||||
|
let exportFormat = $state<ExportFormat>('vcard');
|
||||||
|
let includeArchived = $state(false);
|
||||||
|
let includeNotes = $state(true);
|
||||||
|
let includePhotos = $state(true);
|
||||||
|
let selectedGroupId = $state<string | null>(null);
|
||||||
|
let onlyFavorites = $state(false);
|
||||||
|
let isExporting = $state(false);
|
||||||
|
let exportError = $state<string | null>(null);
|
||||||
|
let exportSuccess = $state(false);
|
||||||
|
|
||||||
|
// Load groups for export filter
|
||||||
|
$effect(() => {
|
||||||
|
if (activeTab === 'export' && groupsStore.groups.length === 0) {
|
||||||
|
groupsStore.loadGroups();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setActiveTab(tab: Tab) {
|
||||||
|
activeTab = tab;
|
||||||
|
updateUrl();
|
||||||
|
// Reset states
|
||||||
|
if (tab === 'import') {
|
||||||
|
resetImportState();
|
||||||
|
} else {
|
||||||
|
resetExportState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setImportSource(source: ImportSource) {
|
||||||
|
importSource = source;
|
||||||
|
updateUrl();
|
||||||
|
resetImportState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUrl() {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('tab', activeTab);
|
||||||
|
if (activeTab === 'import') {
|
||||||
|
url.searchParams.set('source', importSource);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('source');
|
||||||
|
}
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetImportState() {
|
||||||
|
importStep = 'upload';
|
||||||
|
preview = null;
|
||||||
|
selectedFile = null;
|
||||||
|
importResult = null;
|
||||||
|
importError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetExportState() {
|
||||||
|
exportError = null;
|
||||||
|
exportSuccess = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import handlers
|
||||||
|
async function handleFileSelect(file: File) {
|
||||||
|
selectedFile = file;
|
||||||
|
importError = null;
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
preview = await importApi.preview(file);
|
||||||
|
importStep = 'preview';
|
||||||
|
} catch (e) {
|
||||||
|
importError = e instanceof Error ? e.message : 'Fehler beim Verarbeiten der Datei';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport(duplicateAction: DuplicateAction, skipIndices: number[]) {
|
||||||
|
if (!preview) return;
|
||||||
|
|
||||||
|
isImporting = true;
|
||||||
|
importError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
importResult = await importApi.execute(preview.contacts, duplicateAction, skipIndices);
|
||||||
|
importStep = 'result';
|
||||||
|
await contactsStore.loadContacts();
|
||||||
|
} catch (e) {
|
||||||
|
importError = e instanceof Error ? e.message : 'Fehler beim Importieren';
|
||||||
|
} finally {
|
||||||
|
isImporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelImport() {
|
||||||
|
resetImportState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImportDone() {
|
||||||
|
goto('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImportMore() {
|
||||||
|
resetImportState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadTemplate() {
|
||||||
|
try {
|
||||||
|
await importApi.downloadTemplate();
|
||||||
|
} catch (e) {
|
||||||
|
importError = e instanceof Error ? e.message : 'Fehler beim Herunterladen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export handlers
|
||||||
|
async function handleExport() {
|
||||||
|
isExporting = true;
|
||||||
|
exportError = null;
|
||||||
|
exportSuccess = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exportApi.exportContacts({
|
||||||
|
format: exportFormat,
|
||||||
|
groupId: selectedGroupId || undefined,
|
||||||
|
includeFavorites: onlyFavorites || undefined,
|
||||||
|
includeArchived,
|
||||||
|
});
|
||||||
|
exportSuccess = true;
|
||||||
|
} catch (e) {
|
||||||
|
exportError = e instanceof Error ? e.message : 'Export fehlgeschlagen';
|
||||||
|
} finally {
|
||||||
|
isExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Daten - Kontakte</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-4xl mx-auto space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Daten verwalten</h1>
|
||||||
|
<p class="text-muted-foreground mt-1">Kontakte importieren, exportieren und sichern</p>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="btn btn-secondary">Zurück</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Tabs -->
|
||||||
|
<div class="flex gap-1 p-1 bg-muted rounded-xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => setActiveTab('import')}
|
||||||
|
class="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-medium transition-all
|
||||||
|
{activeTab === 'import'
|
||||||
|
? 'bg-card text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => setActiveTab('export')}
|
||||||
|
class="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-medium transition-all
|
||||||
|
{activeTab === 'export'
|
||||||
|
? 'bg-card text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== IMPORT TAB ==================== -->
|
||||||
|
{#if activeTab === 'import'}
|
||||||
|
<!-- Import Source Tabs -->
|
||||||
|
<div class="flex gap-2 border-b border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => setImportSource('file')}
|
||||||
|
class="px-4 py-2 font-medium transition-colors border-b-2 -mb-px
|
||||||
|
{importSource === 'file'
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Datei
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => setImportSource('google')}
|
||||||
|
class="px-4 py-2 font-medium transition-colors border-b-2 -mb-px
|
||||||
|
{importSource === 'google'
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Google
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import Error -->
|
||||||
|
{#if importError && importSource === 'file'}
|
||||||
|
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
|
||||||
|
{importError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- File Import -->
|
||||||
|
{#if importSource === 'file'}
|
||||||
|
{#if importStep === 'upload'}
|
||||||
|
<div class="space-y-6">
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex flex-col items-center justify-center py-12">
|
||||||
|
<div
|
||||||
|
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||||
|
></div>
|
||||||
|
<p class="mt-4 text-muted-foreground">Datei wird verarbeitet...</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<FileUploader onFileSelect={handleFileSelect} />
|
||||||
|
|
||||||
|
<div class="bg-card rounded-xl p-6 space-y-4">
|
||||||
|
<h3 class="font-semibold text-foreground">Unterstützte Formate</h3>
|
||||||
|
<div class="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary flex-shrink-0"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-foreground">vCard (.vcf)</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
Standard-Format für Kontakte, kompatibel mit allen gängigen Apps
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center text-green-500 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-foreground">CSV (.csv)</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
Tabellen-Format, ideal für Excel oder Google Sheets
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4 border-t border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleDownloadTemplate}
|
||||||
|
class="text-primary hover:underline text-sm inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
CSV-Vorlage herunterladen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if importStep === 'preview' && preview}
|
||||||
|
<ImportPreview
|
||||||
|
{preview}
|
||||||
|
onImport={handleImport}
|
||||||
|
onCancel={handleCancelImport}
|
||||||
|
{isImporting}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if importStep === 'result' && importResult}
|
||||||
|
<div class="bg-card rounded-xl p-8 text-center space-y-6">
|
||||||
|
<div
|
||||||
|
class="w-20 h-20 mx-auto rounded-full bg-green-500/10 flex items-center justify-center text-green-500"
|
||||||
|
>
|
||||||
|
<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="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-foreground">Import abgeschlossen</h2>
|
||||||
|
<p class="text-muted-foreground mt-2">Deine Kontakte wurden erfolgreich importiert</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-4 max-w-md mx-auto">
|
||||||
|
<div class="bg-green-500/10 rounded-lg p-4">
|
||||||
|
<div class="text-3xl font-bold text-green-500">{importResult.imported}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">Importiert</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-blue-500/10 rounded-lg p-4">
|
||||||
|
<div class="text-3xl font-bold text-blue-500">{importResult.merged}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">Zusammengeführt</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-500/10 rounded-lg p-4">
|
||||||
|
<div class="text-3xl font-bold text-gray-500">{importResult.skipped}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">Übersprungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if importResult.errors.length > 0}
|
||||||
|
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-left">
|
||||||
|
<h3 class="font-medium text-red-500 mb-2">Fehler</h3>
|
||||||
|
<ul class="text-sm text-red-400 space-y-1">
|
||||||
|
{#each importResult.errors as err}
|
||||||
|
<li>{err.contactName}: {err.error}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-center gap-3">
|
||||||
|
<button type="button" onclick={handleImportMore} class="btn btn-secondary">
|
||||||
|
Weitere importieren
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={handleImportDone} class="btn btn-primary">
|
||||||
|
Fertig
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Google Import -->
|
||||||
|
{#if importSource === 'google'}
|
||||||
|
<GoogleImport />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ==================== EXPORT TAB ==================== -->
|
||||||
|
{#if activeTab === 'export'}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Success Message -->
|
||||||
|
{#if exportSuccess}
|
||||||
|
<div
|
||||||
|
class="bg-green-500/10 border border-green-500/20 rounded-lg p-4 text-green-600 dark:text-green-400 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 flex-shrink-0" 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>
|
||||||
|
Export erfolgreich! Die Datei wurde heruntergeladen.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
{#if exportError}
|
||||||
|
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
|
||||||
|
{exportError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Format Selection -->
|
||||||
|
<div class="bg-card rounded-xl p-6 space-y-4">
|
||||||
|
<h3 class="font-semibold text-foreground">Format wählen</h3>
|
||||||
|
<div class="grid sm:grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (exportFormat = 'vcard')}
|
||||||
|
class="p-4 rounded-lg border-2 transition-colors text-left
|
||||||
|
{exportFormat === 'vcard'
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-border hover:border-muted-foreground'}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center text-primary"
|
||||||
|
>
|
||||||
|
<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="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-foreground">vCard</div>
|
||||||
|
<div class="text-sm text-muted-foreground">.vcf - Universelles Kontaktformat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (exportFormat = 'csv')}
|
||||||
|
class="p-4 rounded-lg border-2 transition-colors text-left
|
||||||
|
{exportFormat === 'csv'
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-border hover:border-muted-foreground'}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-lg bg-green-500/10 flex items-center justify-center text-green-500"
|
||||||
|
>
|
||||||
|
<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="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-foreground">CSV</div>
|
||||||
|
<div class="text-sm text-muted-foreground">.csv - Für Excel & Tabellen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Options -->
|
||||||
|
<div class="bg-card rounded-xl p-6 space-y-4">
|
||||||
|
<h3 class="font-semibold text-foreground">Filter</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Group Filter -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-foreground mb-2">Gruppe</label>
|
||||||
|
<select
|
||||||
|
bind:value={selectedGroupId}
|
||||||
|
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||||
|
>
|
||||||
|
<option value={null}>Alle Kontakte</option>
|
||||||
|
{#each groupsStore.groups as group}
|
||||||
|
<option value={group.id}>{group.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Favorites Only -->
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={onlyFavorites}
|
||||||
|
class="w-5 h-5 rounded border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<span class="text-foreground">Nur Favoriten exportieren</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export Options -->
|
||||||
|
<div class="bg-card rounded-xl p-6 space-y-4">
|
||||||
|
<h3 class="font-semibold text-foreground">Optionen</h3>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={includeNotes}
|
||||||
|
class="w-5 h-5 rounded border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span class="text-foreground">Notizen einschließen</span>
|
||||||
|
<p class="text-sm text-muted-foreground">Notizen zu Kontakten mit exportieren</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={includePhotos}
|
||||||
|
class="w-5 h-5 rounded border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span class="text-foreground">Fotos einschließen</span>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Kontaktfotos mit exportieren (größere Datei)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={includeArchived}
|
||||||
|
class="w-5 h-5 rounded border-border text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span class="text-foreground">Archivierte einschließen</span>
|
||||||
|
<p class="text-sm text-muted-foreground">Auch archivierte Kontakte exportieren</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export Button -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleExport}
|
||||||
|
disabled={isExporting}
|
||||||
|
class="btn btn-primary px-8"
|
||||||
|
>
|
||||||
|
{#if isExporting}
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
||||||
|
></span>
|
||||||
|
Exportiere...
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Kontakte exportieren
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -1,16 +1,23 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
import { contactsApi } from '$lib/api/contacts';
|
import { contactsApi } from '$lib/api/contacts';
|
||||||
import type { Contact } from '$lib/api/contacts';
|
import type { Contact } from '$lib/api/contacts';
|
||||||
|
import FavoriteCardView from '$lib/components/favorites/FavoriteCardView.svelte';
|
||||||
|
import FavoriteListView from '$lib/components/favorites/FavoriteListView.svelte';
|
||||||
|
import FavoriteAlphabetView from '$lib/components/favorites/FavoriteAlphabetView.svelte';
|
||||||
import '$lib/i18n';
|
import '$lib/i18n';
|
||||||
|
|
||||||
|
type ViewMode = 'cards' | 'list' | 'alphabet';
|
||||||
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let contacts = $state<Contact[]>([]);
|
let contacts = $state<Contact[]>([]);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
|
let viewMode = $state<ViewMode>('cards');
|
||||||
|
|
||||||
const filteredContacts = $derived(() => {
|
const filteredContacts = $derived.by(() => {
|
||||||
if (!searchQuery.trim()) return contacts;
|
if (!searchQuery.trim()) return contacts;
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return contacts.filter((c) => {
|
return contacts.filter((c) => {
|
||||||
|
|
@ -18,7 +25,9 @@
|
||||||
return (
|
return (
|
||||||
name.includes(query) ||
|
name.includes(query) ||
|
||||||
c.email?.toLowerCase().includes(query) ||
|
c.email?.toLowerCase().includes(query) ||
|
||||||
c.company?.toLowerCase().includes(query)
|
c.company?.toLowerCase().includes(query) ||
|
||||||
|
c.phone?.includes(query) ||
|
||||||
|
c.mobile?.includes(query)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -44,73 +53,211 @@
|
||||||
return contact.email || 'Unbekannt';
|
return contact.email || 'Unbekannt';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitials(contact: Contact) {
|
|
||||||
const first = contact.firstName?.[0] || '';
|
|
||||||
const last = contact.lastName?.[0] || '';
|
|
||||||
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleContactClick(id: string) {
|
function handleContactClick(id: string) {
|
||||||
goto(`/contacts/${id}`);
|
goto(`/contacts/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleFavorite(e: MouseEvent, contact: Contact) {
|
async function handleToggleFavorite(e: MouseEvent, id: string) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
await contactsApi.toggleFavorite(contact.id);
|
await contactsApi.toggleFavorite(id);
|
||||||
// Remove from list since it's no longer a favorite
|
// Remove from list since it's no longer a favorite
|
||||||
contacts = contacts.filter((c) => c.id !== contact.id);
|
contacts = contacts.filter((c) => c.id !== id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Fehler beim Entfernen';
|
error = e instanceof Error ? e.message : 'Fehler beim Entfernen';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(loadFavorites);
|
// Save view mode to localStorage
|
||||||
|
function setViewMode(mode: ViewMode) {
|
||||||
|
viewMode = mode;
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('favorites-view-mode', mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadFavorites();
|
||||||
|
// Load saved view mode
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem('favorites-view-mode') as ViewMode | null;
|
||||||
|
if (saved && ['cards', 'list', 'alphabet'].includes(saved)) {
|
||||||
|
viewMode = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Favoriten - Contacts</title>
|
<title>Favoriten - Contacts</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="page-container">
|
<div class="favorites-page">
|
||||||
<!-- Header -->
|
<!-- Hero Header -->
|
||||||
<header class="header">
|
<div class="hero-header">
|
||||||
<a href="/" class="back-button" aria-label="Zurück">
|
<div class="hero-content">
|
||||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="hero-icon">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
<svg fill="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path
|
||||||
</a>
|
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"
|
||||||
<h1 class="title">Favoriten</h1>
|
/>
|
||||||
<div class="title-icon">
|
</svg>
|
||||||
<svg class="icon" fill="currentColor" viewBox="0 0 24 24">
|
</div>
|
||||||
|
<div class="hero-text">
|
||||||
|
<h1 class="hero-title">Favoriten</h1>
|
||||||
|
<p class="hero-subtitle">
|
||||||
|
{#if contacts.length === 0}
|
||||||
|
Markiere Kontakte als Favoriten für schnellen Zugriff
|
||||||
|
{:else}
|
||||||
|
{contacts.length} Favorit{contacts.length !== 1 ? 'en' : ''} für schnellen Zugriff
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
{#if contacts.length > 0}
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon stat-icon-contacts">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<span class="stat-value">{contacts.length}</span>
|
||||||
|
<span class="stat-label">Favoriten</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon stat-icon-email">
|
||||||
|
<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>
|
||||||
|
<div class="stat-content">
|
||||||
|
<span class="stat-value">{contacts.filter((c) => c.email).length}</span>
|
||||||
|
<span class="stat-label">Mit E-Mail</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon stat-icon-phone">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<span class="stat-value">{contacts.filter((c) => c.phone || c.mobile).length}</span>
|
||||||
|
<span class="stat-label">Mit Telefon</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls Bar -->
|
||||||
|
<div class="controls-bar">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<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"
|
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>
|
</svg>
|
||||||
</div>
|
<input
|
||||||
</header>
|
type="text"
|
||||||
|
placeholder="Favoriten durchsuchen..."
|
||||||
<!-- Search -->
|
bind:value={searchQuery}
|
||||||
<div class="search-wrapper">
|
class="search-input"
|
||||||
<svg class="search-icon" 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>
|
{#if searchQuery}
|
||||||
<input
|
<button class="search-clear" onclick={() => (searchQuery = '')}>
|
||||||
type="text"
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
placeholder="Favoriten durchsuchen..."
|
<path
|
||||||
bind:value={searchQuery}
|
stroke-linecap="round"
|
||||||
class="search-input"
|
stroke-linejoin="round"
|
||||||
/>
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Mode Toggle -->
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="view-btn"
|
||||||
|
class:active={viewMode === 'cards'}
|
||||||
|
onclick={() => setViewMode('cards')}
|
||||||
|
title="Kachelansicht"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="view-btn"
|
||||||
|
class:active={viewMode === 'list'}
|
||||||
|
onclick={() => setViewMode('list')}
|
||||||
|
title="Listenansicht"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="view-btn"
|
||||||
|
class:active={viewMode === 'alphabet'}
|
||||||
|
onclick={() => setViewMode('alphabet')}
|
||||||
|
title="Alphabetisch"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" 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>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="error-banner" role="alert">
|
<div class="error-banner" role="alert">
|
||||||
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|
@ -126,6 +273,7 @@
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading-container">
|
<div class="loading-container">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
|
<p class="loading-text">Favoriten werden geladen...</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if contacts.length === 0}
|
{:else if contacts.length === 0}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
|
|
@ -134,17 +282,18 @@
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="1.5"
|
||||||
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"
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="empty-title">Keine Favoriten</h2>
|
<h2 class="empty-title">Keine Favoriten</h2>
|
||||||
<p class="empty-description">
|
<p class="empty-description">
|
||||||
Markiere Kontakte als Favoriten, um sie hier schnell zu finden.
|
Markiere Kontakte als Favoriten, um sie hier schnell wiederzufinden. Klicke einfach auf das
|
||||||
|
Herz-Symbol bei einem Kontakt.
|
||||||
</p>
|
</p>
|
||||||
<a href="/" class="btn btn-primary">
|
<a href="/" class="btn-primary">
|
||||||
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
|
|
@ -152,133 +301,185 @@
|
||||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Zu Kontakten
|
Zu allen Kontakten
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{:else if filteredContacts().length === 0}
|
{:else if filteredContacts.length === 0}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-icon">
|
<div class="empty-icon empty-icon-search">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="1.5"
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="empty-title">Keine Ergebnisse</h2>
|
<h2 class="empty-title">Keine Ergebnisse</h2>
|
||||||
<p class="empty-description">Keine Favoriten gefunden für "{searchQuery}"</p>
|
<p class="empty-description">Keine Favoriten gefunden für "{searchQuery}"</p>
|
||||||
|
<button class="btn-secondary" onclick={() => (searchQuery = '')}>Suche zurücksetzen</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="contacts-list">
|
<!-- View Content -->
|
||||||
{#each filteredContacts() as contact (contact.id)}
|
<div class="view-content">
|
||||||
<div
|
{#if viewMode === 'cards'}
|
||||||
role="button"
|
<FavoriteCardView
|
||||||
tabindex="0"
|
contacts={filteredContacts}
|
||||||
onclick={() => handleContactClick(contact.id)}
|
onContactClick={handleContactClick}
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleContactClick(contact.id)}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
class="contact-card"
|
/>
|
||||||
>
|
{:else if viewMode === 'list'}
|
||||||
<!-- Avatar -->
|
<FavoriteListView
|
||||||
<div class="avatar">
|
contacts={filteredContacts}
|
||||||
{#if contact.photoUrl}
|
onContactClick={handleContactClick}
|
||||||
<img src={contact.photoUrl} alt={getDisplayName(contact)} />
|
onToggleFavorite={handleToggleFavorite}
|
||||||
{:else}
|
/>
|
||||||
{getInitials(contact)}
|
{:else}
|
||||||
{/if}
|
<FavoriteAlphabetView
|
||||||
</div>
|
contacts={filteredContacts}
|
||||||
|
onContactClick={handleContactClick}
|
||||||
<!-- Contact Info -->
|
onToggleFavorite={handleToggleFavorite}
|
||||||
<div class="contact-info">
|
/>
|
||||||
<h3 class="contact-name">{getDisplayName(contact)}</h3>
|
{/if}
|
||||||
{#if contact.company || contact.jobTitle}
|
|
||||||
<p class="contact-subtitle">
|
|
||||||
{[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{#if contact.email}
|
|
||||||
<p class="contact-email">{contact.email}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Favorite button -->
|
|
||||||
<button
|
|
||||||
onclick={(e) => handleToggleFavorite(e, contact)}
|
|
||||||
class="favorite-btn"
|
|
||||||
aria-label="Aus Favoriten entfernen"
|
|
||||||
>
|
|
||||||
<svg class="heart-icon" 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>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="contacts-count">{contacts.length} Favorit{contacts.length !== 1 ? 'en' : ''}</p>
|
<!-- Footer count -->
|
||||||
|
<p class="footer-count">
|
||||||
|
{filteredContacts.length} von {contacts.length} Favorit{contacts.length !== 1 ? 'en' : ''}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page-container {
|
.favorites-page {
|
||||||
max-width: 640px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 1rem 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Hero Header */
|
||||||
.header {
|
.hero-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 2rem;
|
||||||
|
background: linear-gradient(135deg, hsl(0 84% 60% / 0.08), hsl(340 82% 52% / 0.05));
|
||||||
|
border: 1px solid hsl(0 84% 60% / 0.15);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1.5rem;
|
||||||
padding: 1rem 0;
|
margin-bottom: 1.5rem;
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: hsl(var(--color-background));
|
|
||||||
z-index: 10;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button {
|
.hero-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 4.5rem;
|
||||||
|
height: 4.5rem;
|
||||||
|
background: linear-gradient(135deg, #ef4444, #ec4899);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 8px 24px -4px hsl(0 84% 60% / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-icon svg {
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
border-radius: 50%;
|
|
||||||
background: hsl(var(--color-muted));
|
|
||||||
color: hsl(var(--color-foreground));
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-button:hover {
|
.hero-text {
|
||||||
background: hsl(var(--color-surface-hover));
|
|
||||||
transform: translateX(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: hsl(var(--color-foreground));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-icon {
|
.hero-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: hsl(var(--background) / 0.8);
|
||||||
|
border: 1px solid hsl(var(--border) / 0.5);
|
||||||
|
border-radius: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon svg {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-contacts {
|
||||||
|
background: hsl(0 84% 60% / 0.15);
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search */
|
.stat-icon-email {
|
||||||
|
background: hsl(var(--primary) / 0.15);
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon-phone {
|
||||||
|
background: hsl(142 76% 36% / 0.15);
|
||||||
|
color: hsl(142 76% 36%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls Bar */
|
||||||
|
.controls-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.search-wrapper {
|
.search-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 1.5rem;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.search-icon {
|
||||||
|
|
@ -288,29 +489,84 @@
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
color: hsl(var(--color-muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.875rem 1rem 0.875rem 3rem;
|
padding: 0.875rem 2.5rem 0.875rem 3rem;
|
||||||
border: 1.5px solid hsl(var(--color-border));
|
border: 1.5px solid hsl(var(--border));
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.875rem;
|
||||||
background: hsl(var(--color-input));
|
background: hsl(var(--background));
|
||||||
color: hsl(var(--color-foreground));
|
color: hsl(var(--foreground));
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input:focus {
|
.search-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: hsl(var(--color-primary));
|
border-color: #ef4444;
|
||||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
|
box-shadow: 0 0 0 3px hsl(0 84% 60% / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input::placeholder {
|
.search-input::placeholder {
|
||||||
color: hsl(var(--color-muted-foreground) / 0.6);
|
color: hsl(var(--muted-foreground) / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
border: none;
|
||||||
|
border-radius: 9999px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear:hover {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.2);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View Toggle */
|
||||||
|
.view-toggle {
|
||||||
|
display: flex;
|
||||||
|
background: hsl(var(--muted) / 0.5);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn:hover {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn.active {
|
||||||
|
background: hsl(var(--background));
|
||||||
|
color: #ef4444;
|
||||||
|
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Error */
|
/* Error */
|
||||||
|
|
@ -318,11 +574,11 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1rem;
|
padding: 1rem 1.25rem;
|
||||||
background: hsl(var(--color-error) / 0.1);
|
background: hsl(var(--destructive) / 0.1);
|
||||||
border: 1px solid hsl(var(--color-error) / 0.3);
|
border: 1px solid hsl(var(--destructive) / 0.3);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.875rem;
|
||||||
color: hsl(var(--color-error));
|
color: hsl(var(--destructive));
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -344,19 +600,26 @@
|
||||||
/* Loading */
|
/* Loading */
|
||||||
.loading-container {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
padding: 4rem 0;
|
padding: 4rem 0;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 2.5rem;
|
width: 3rem;
|
||||||
height: 2.5rem;
|
height: 3rem;
|
||||||
border: 3px solid hsl(var(--color-muted));
|
border: 3px solid hsl(var(--muted));
|
||||||
border-top-color: hsl(var(--color-primary));
|
border-top-color: #ef4444;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
|
|
@ -368,15 +631,15 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 3rem 1rem;
|
padding: 4rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon {
|
.empty-icon {
|
||||||
width: 5rem;
|
width: 6rem;
|
||||||
height: 5rem;
|
height: 6rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #fecaca 0%, #fca5a5 100%);
|
background: linear-gradient(135deg, hsl(0 84% 60% / 0.15), hsl(340 82% 52% / 0.1));
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -385,168 +648,117 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-icon svg {
|
.empty-icon svg {
|
||||||
width: 2.5rem;
|
width: 3rem;
|
||||||
height: 2.5rem;
|
height: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon-search {
|
||||||
|
background: linear-gradient(135deg, hsl(var(--muted)), hsl(var(--muted) / 0.5));
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 1.25rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: hsl(var(--color-foreground));
|
color: hsl(var(--foreground));
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-description {
|
.empty-description {
|
||||||
color: hsl(var(--color-muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.75rem;
|
||||||
max-width: 280px;
|
max-width: 320px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Contacts List */
|
/* Buttons */
|
||||||
.contacts-list {
|
.btn-primary {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
justify-content: center;
|
||||||
padding: 1rem;
|
gap: 0.5rem;
|
||||||
background: hsl(var(--color-surface));
|
padding: 0.875rem 1.75rem;
|
||||||
border: 1px solid hsl(var(--color-border));
|
background: linear-gradient(135deg, #ef4444, #ec4899);
|
||||||
|
color: white;
|
||||||
border-radius: 0.875rem;
|
border-radius: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 4px 12px hsl(0 84% 60% / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-card:hover {
|
.btn-primary:hover {
|
||||||
border-color: hsl(var(--color-primary) / 0.3);
|
transform: translateY(-2px);
|
||||||
transform: translateY(-1px);
|
box-shadow: 0 6px 20px hsl(0 84% 60% / 0.4);
|
||||||
box-shadow: 0 4px 12px hsl(var(--color-foreground) / 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.btn-secondary {
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
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: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-name {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: hsl(var(--color-foreground));
|
|
||||||
margin-bottom: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-subtitle {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: hsl(var(--color-muted-foreground));
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-email {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: hsl(var(--color-muted-foreground) / 0.8);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.favorite-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.favorite-btn:hover {
|
|
||||||
background: hsl(var(--color-error) / 0.1);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heart-icon {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Count */
|
|
||||||
.contacts-count {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: hsl(var(--color-muted-foreground));
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Button */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
border: none;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
border: none;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-secondary:hover {
|
||||||
background: hsl(var(--color-primary));
|
background: hsl(var(--muted-foreground) / 0.15);
|
||||||
color: hsl(var(--color-primary-foreground));
|
|
||||||
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
/* View Content */
|
||||||
transform: translateY(-1px);
|
.view-content {
|
||||||
box-shadow: 0 6px 16px hsl(var(--color-primary) / 0.4);
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Icons */
|
/* Footer */
|
||||||
.icon {
|
.footer-count {
|
||||||
width: 1.25rem;
|
text-align: center;
|
||||||
height: 1.25rem;
|
font-size: 0.9375rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-sm {
|
/* Responsive */
|
||||||
width: 1rem;
|
@media (max-width: 768px) {
|
||||||
height: 1rem;
|
.hero-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import FileUploader from '$lib/components/import/FileUploader.svelte';
|
|
||||||
import ImportPreview from '$lib/components/import/ImportPreview.svelte';
|
|
||||||
import GoogleImport from '$lib/components/import/GoogleImport.svelte';
|
|
||||||
import { importApi, type ImportPreviewResponse, type DuplicateAction } from '$lib/api/import';
|
|
||||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
|
||||||
import '$lib/i18n';
|
|
||||||
|
|
||||||
type Tab = 'file' | 'google';
|
|
||||||
type Step = 'upload' | 'preview' | 'result';
|
|
||||||
|
|
||||||
// Get initial tab from URL
|
|
||||||
let activeTab = $state<Tab>(($page.url.searchParams.get('tab') as Tab) || 'file');
|
|
||||||
|
|
||||||
let step = $state<Step>('upload');
|
|
||||||
let isLoading = $state(false);
|
|
||||||
let isImporting = $state(false);
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
let selectedFile = $state<File | null>(null);
|
|
||||||
let preview = $state<ImportPreviewResponse | null>(null);
|
|
||||||
let result = $state<{
|
|
||||||
imported: number;
|
|
||||||
skipped: number;
|
|
||||||
merged: number;
|
|
||||||
errors: { index: number; contactName: string; error: string }[];
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
function setActiveTab(tab: Tab) {
|
|
||||||
activeTab = tab;
|
|
||||||
// Reset file import state when switching tabs
|
|
||||||
step = 'upload';
|
|
||||||
preview = null;
|
|
||||||
selectedFile = null;
|
|
||||||
result = null;
|
|
||||||
error = null;
|
|
||||||
// Update URL without navigation
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.set('tab', tab);
|
|
||||||
window.history.replaceState({}, '', url.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFileSelect(file: File) {
|
|
||||||
selectedFile = file;
|
|
||||||
error = null;
|
|
||||||
isLoading = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
preview = await importApi.preview(file);
|
|
||||||
step = 'preview';
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Fehler beim Verarbeiten der Datei';
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleImport(duplicateAction: DuplicateAction, skipIndices: number[]) {
|
|
||||||
if (!preview) return;
|
|
||||||
|
|
||||||
isImporting = true;
|
|
||||||
error = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
result = await importApi.execute(preview.contacts, duplicateAction, skipIndices);
|
|
||||||
step = 'result';
|
|
||||||
// Refresh contacts list
|
|
||||||
await contactsStore.loadContacts();
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Fehler beim Importieren';
|
|
||||||
} finally {
|
|
||||||
isImporting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancel() {
|
|
||||||
step = 'upload';
|
|
||||||
preview = null;
|
|
||||||
selectedFile = null;
|
|
||||||
error = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDone() {
|
|
||||||
goto('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImportMore() {
|
|
||||||
step = 'upload';
|
|
||||||
preview = null;
|
|
||||||
selectedFile = null;
|
|
||||||
result = null;
|
|
||||||
error = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDownloadTemplate() {
|
|
||||||
try {
|
|
||||||
await importApi.downloadTemplate();
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Fehler beim Herunterladen';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$_('import.title')} - Contacts</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="max-w-4xl mx-auto space-y-6">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold text-foreground">{$_('import.title')}</h1>
|
|
||||||
<p class="text-muted-foreground mt-1">{$_('import.subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
<a href="/" class="btn btn-secondary">
|
|
||||||
{$_('common.back')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="flex gap-2 border-b border-border">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => setActiveTab('file')}
|
|
||||||
class="px-4 py-2 font-medium transition-colors border-b-2 -mb-px
|
|
||||||
{activeTab === 'file'
|
|
||||||
? 'border-primary text-primary'
|
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{$_('import.tabs.file')}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => setActiveTab('google')}
|
|
||||||
class="px-4 py-2 font-medium transition-colors border-b-2 -mb-px
|
|
||||||
{activeTab === 'google'
|
|
||||||
? 'border-primary text-primary'
|
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{$_('import.tabs.google')}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error message (only for file import) -->
|
|
||||||
{#if error && activeTab === 'file'}
|
|
||||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- File Import Tab -->
|
|
||||||
{#if activeTab === 'file'}
|
|
||||||
<!-- Step: Upload -->
|
|
||||||
{#if step === 'upload'}
|
|
||||||
<div class="space-y-6">
|
|
||||||
{#if isLoading}
|
|
||||||
<div class="flex flex-col items-center justify-center py-12">
|
|
||||||
<div
|
|
||||||
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
|
||||||
></div>
|
|
||||||
<p class="mt-4 text-muted-foreground">{$_('import.processing')}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<FileUploader onFileSelect={handleFileSelect} />
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={handleDownloadTemplate}
|
|
||||||
class="text-primary hover:underline text-sm inline-flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{$_('import.downloadTemplate')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Step: Preview -->
|
|
||||||
{#if step === 'preview' && preview}
|
|
||||||
<ImportPreview {preview} onImport={handleImport} onCancel={handleCancel} {isImporting} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Step: Result -->
|
|
||||||
{#if step === 'result' && result}
|
|
||||||
<div class="bg-card rounded-xl p-8 text-center space-y-6">
|
|
||||||
<div
|
|
||||||
class="w-20 h-20 mx-auto rounded-full bg-green-500/10 flex items-center justify-center text-green-500"
|
|
||||||
>
|
|
||||||
<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="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-foreground">{$_('import.result.title')}</h2>
|
|
||||||
<p class="text-muted-foreground mt-2">{$_('import.result.subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-4 max-w-md mx-auto">
|
|
||||||
<div class="bg-green-500/10 rounded-lg p-4">
|
|
||||||
<div class="text-3xl font-bold text-green-500">{result.imported}</div>
|
|
||||||
<div class="text-sm text-muted-foreground">{$_('import.result.imported')}</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-blue-500/10 rounded-lg p-4">
|
|
||||||
<div class="text-3xl font-bold text-blue-500">{result.merged}</div>
|
|
||||||
<div class="text-sm text-muted-foreground">{$_('import.result.merged')}</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-500/10 rounded-lg p-4">
|
|
||||||
<div class="text-3xl font-bold text-gray-500">{result.skipped}</div>
|
|
||||||
<div class="text-sm text-muted-foreground">{$_('import.result.skipped')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if result.errors.length > 0}
|
|
||||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-left">
|
|
||||||
<h3 class="font-medium text-red-500 mb-2">{$_('import.result.errors')}</h3>
|
|
||||||
<ul class="text-sm text-red-400 space-y-1">
|
|
||||||
{#each result.errors as err}
|
|
||||||
<li>{err.contactName}: {err.error}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex justify-center gap-3">
|
|
||||||
<button type="button" onclick={handleImportMore} class="btn btn-secondary">
|
|
||||||
{$_('import.result.importMore')}
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick={handleDone} class="btn btn-primary">
|
|
||||||
{$_('import.result.done')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Google Import Tab -->
|
|
||||||
{#if activeTab === 'google'}
|
|
||||||
<GoogleImport />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
@ -53,12 +53,6 @@
|
||||||
{ value: 'yyyy-MM-dd', label: 'JJJJ-MM-TT (ISO)' },
|
{ value: 'yyyy-MM-dd', label: 'JJJJ-MM-TT (ISO)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const exportFormatOptions = [
|
|
||||||
{ value: 'vcf', label: 'vCard (.vcf)' },
|
|
||||||
{ value: 'csv', label: 'CSV (.csv)' },
|
|
||||||
{ value: 'json', label: 'JSON (.json)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const duplicateSensitivityOptions = [
|
const duplicateSensitivityOptions = [
|
||||||
{ value: 'strict', label: 'Streng' },
|
{ value: 'strict', label: 'Streng' },
|
||||||
{ value: 'normal', label: 'Normal' },
|
{ value: 'normal', label: 'Normal' },
|
||||||
|
|
@ -195,8 +189,7 @@
|
||||||
description="Standard-Sortierung der Kontakte"
|
description="Standard-Sortierung der Kontakte"
|
||||||
options={sortByOptions}
|
options={sortByOptions}
|
||||||
value={contactsSettings.sortBy}
|
value={contactsSettings.sortBy}
|
||||||
onchange={(v: string | number | null) =>
|
onchange={(v: string | number | null) => contactsSettings.set('sortBy', v as ContactSortBy)}
|
||||||
contactsSettings.set('sortBy', v as ContactSortBy)}
|
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -385,7 +378,7 @@
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<!-- Import/Export Section -->
|
<!-- Import/Export Section -->
|
||||||
<SettingsSection title="Import & Export">
|
<SettingsSection title="Daten">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
|
|
@ -398,13 +391,10 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<SettingsCard>
|
<SettingsCard>
|
||||||
<SettingsSelect
|
<SettingsRow
|
||||||
label="Standard-Exportformat"
|
label="Kontakte importieren"
|
||||||
description="Bevorzugtes Format für Kontakt-Export"
|
description="Aus Datei oder Google importieren"
|
||||||
options={exportFormatOptions}
|
href="/data?tab=import"
|
||||||
value={contactsSettings.defaultExportFormat}
|
|
||||||
onchange={(v: string | number | null) =>
|
|
||||||
contactsSettings.set('defaultExportFormat', v as 'vcf' | 'csv' | 'json')}
|
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -412,35 +402,16 @@
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SettingsSelect>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsToggle
|
<SettingsRow
|
||||||
label="Notizen exportieren"
|
label="Kontakte exportieren"
|
||||||
description="Notizen beim Export mit einschließen"
|
description="Als vCard oder CSV herunterladen"
|
||||||
isOn={contactsSettings.includeNotesInExport}
|
href="/data?tab=export"
|
||||||
onToggle={(v) => contactsSettings.set('includeNotesInExport', v)}
|
|
||||||
>
|
|
||||||
{#snippet 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>
|
|
||||||
{/snippet}
|
|
||||||
</SettingsToggle>
|
|
||||||
|
|
||||||
<SettingsToggle
|
|
||||||
label="Fotos exportieren"
|
|
||||||
description="Kontaktfotos beim Export mit einschließen"
|
|
||||||
isOn={contactsSettings.includePhotosInExport}
|
|
||||||
onToggle={(v) => contactsSettings.set('includePhotosInExport', v)}
|
|
||||||
border={false}
|
border={false}
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
|
|
@ -449,11 +420,11 @@
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SettingsToggle>
|
</SettingsRow>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
|
|
||||||
847
apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte
Normal file
847
apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte
Normal file
|
|
@ -0,0 +1,847 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { _ } from 'svelte-i18n';
|
||||||
|
import { tagsApi } from '$lib/api/contacts';
|
||||||
|
import type { ContactTag } from '$lib/api/contacts';
|
||||||
|
|
||||||
|
let loading = $state(true);
|
||||||
|
let tags = $state<ContactTag[]>([]);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let searchQuery = $state('');
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
let showModal = $state(false);
|
||||||
|
let editingTag = $state<ContactTag | null>(null);
|
||||||
|
let tagName = $state('');
|
||||||
|
let tagColor = $state('#6366f1');
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
const filteredTags = $derived.by(() => {
|
||||||
|
if (!searchQuery.trim()) return tags;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return tags.filter((t) => t.name.toLowerCase().includes(query));
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorOptions = [
|
||||||
|
'#ef4444', // red
|
||||||
|
'#f97316', // orange
|
||||||
|
'#f59e0b', // amber
|
||||||
|
'#84cc16', // lime
|
||||||
|
'#22c55e', // green
|
||||||
|
'#14b8a6', // teal
|
||||||
|
'#06b6d4', // cyan
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#6366f1', // indigo
|
||||||
|
'#8b5cf6', // violet
|
||||||
|
'#a855f7', // purple
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#64748b', // slate
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadTags() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const response = await tagsApi.list();
|
||||||
|
tags = response.tags || [];
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : $_('messages.error');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
editingTag = null;
|
||||||
|
tagName = '';
|
||||||
|
tagColor = '#6366f1';
|
||||||
|
showModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(tag: ContactTag) {
|
||||||
|
editingTag = tag;
|
||||||
|
tagName = tag.name;
|
||||||
|
tagColor = tag.color || '#6366f1';
|
||||||
|
showModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal = false;
|
||||||
|
editingTag = null;
|
||||||
|
tagName = '';
|
||||||
|
tagColor = '#6366f1';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!tagName.trim()) return;
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
if (editingTag) {
|
||||||
|
const response = await tagsApi.update(editingTag.id, {
|
||||||
|
name: tagName.trim(),
|
||||||
|
color: tagColor,
|
||||||
|
});
|
||||||
|
tags = tags.map((t) => (t.id === editingTag!.id ? response.tag : t));
|
||||||
|
} else {
|
||||||
|
const response = await tagsApi.create({
|
||||||
|
name: tagName.trim(),
|
||||||
|
color: tagColor,
|
||||||
|
});
|
||||||
|
tags = [...tags, response.tag];
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : $_('messages.error');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(tag: ContactTag) {
|
||||||
|
if (!confirm($_('tags.confirmDelete', { values: { name: tag.name } }))) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tagsApi.delete(tag.id);
|
||||||
|
tags = tags.filter((t) => t.id !== tag.id);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : $_('messages.error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadTags);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$_('tags.title')} - Contacts</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<a href="/" class="back-button" aria-label={$_('common.back')}>
|
||||||
|
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h1 class="title">{$_('tags.title')}</h1>
|
||||||
|
<button onclick={openCreateModal} class="add-button" aria-label={$_('tags.new')}>
|
||||||
|
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<svg class="search-icon" 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>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={$_('tags.search')}
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#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}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading-container">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
{:else if tags.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="empty-title">{$_('tags.noTags')}</h2>
|
||||||
|
<p class="empty-description">{$_('tags.createFirst')}</p>
|
||||||
|
<button onclick={openCreateModal} class="btn btn-primary">
|
||||||
|
<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 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{$_('tags.new')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if filteredTags.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<svg 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>
|
||||||
|
<h2 class="empty-title">{$_('tags.noResults')}</h2>
|
||||||
|
<p class="empty-description">{$_('tags.noResultsFor', { values: { query: searchQuery } })}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="tags-grid">
|
||||||
|
{#each filteredTags as tag (tag.id)}
|
||||||
|
<div class="tag-card">
|
||||||
|
<div class="tag-color" style="background-color: {tag.color || '#6366f1'}">
|
||||||
|
<svg class="tag-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="tag-info">
|
||||||
|
<h3 class="tag-name">{tag.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="tag-actions">
|
||||||
|
<button
|
||||||
|
onclick={() => openEditModal(tag)}
|
||||||
|
class="action-button"
|
||||||
|
aria-label={$_('actions.edit')}
|
||||||
|
>
|
||||||
|
<svg class="icon-sm" 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>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(tag)}
|
||||||
|
class="action-button delete"
|
||||||
|
aria-label={$_('actions.delete')}
|
||||||
|
>
|
||||||
|
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="tags-count">
|
||||||
|
{tags.length}
|
||||||
|
{tags.length === 1 ? $_('tags.tagSingular') : $_('tags.tagPlural')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Modal -->
|
||||||
|
{#if showModal}
|
||||||
|
<div class="modal-backdrop" onclick={closeModal} role="presentation">
|
||||||
|
<div
|
||||||
|
class="modal"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="modal-title"
|
||||||
|
>
|
||||||
|
<header class="modal-header">
|
||||||
|
<h2 id="modal-title" class="modal-title">
|
||||||
|
{editingTag ? $_('tags.edit') : $_('tags.new')}
|
||||||
|
</h2>
|
||||||
|
<button onclick={closeModal} class="modal-close" aria-label={$_('common.cancel')}>
|
||||||
|
<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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tag-name" class="form-label">{$_('tags.name')}</label>
|
||||||
|
<input
|
||||||
|
id="tag-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={tagName}
|
||||||
|
placeholder={$_('tags.namePlaceholder')}
|
||||||
|
class="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{$_('tags.color')}</label>
|
||||||
|
<div class="color-picker">
|
||||||
|
{#each colorOptions as color}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="color-option"
|
||||||
|
class:selected={tagColor === color}
|
||||||
|
style="background-color: {color}"
|
||||||
|
onclick={() => (tagColor = color)}
|
||||||
|
aria-label={color}
|
||||||
|
>
|
||||||
|
{#if tagColor === color}
|
||||||
|
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="3"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{$_('tags.preview')}</label>
|
||||||
|
<div class="tag-preview">
|
||||||
|
<span class="preview-tag" style="background-color: {tagColor}">
|
||||||
|
{tagName || $_('tags.namePlaceholder')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="modal-footer">
|
||||||
|
<button onclick={closeModal} class="btn btn-secondary" disabled={saving}>
|
||||||
|
{$_('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onclick={handleSave} class="btn btn-primary" disabled={saving || !tagName.trim()}>
|
||||||
|
{#if saving}
|
||||||
|
<span class="btn-spinner"></span>
|
||||||
|
{/if}
|
||||||
|
{editingTag ? $_('actions.save') : $_('actions.create')}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page-container {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 80px;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
z-index: 10;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
top: 90px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button:hover {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.2);
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-button:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.search-wrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1rem 0.875rem 3rem;
|
||||||
|
border: 1.5px solid hsl(var(--border));
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.error-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: hsl(0 84% 60% / 0.1);
|
||||||
|
border: 1px solid hsl(0 84% 60% / 0.3);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
color: hsl(0 84% 60%);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border: 3px solid hsl(var(--muted));
|
||||||
|
border-top-color: hsl(var(--primary));
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon svg {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-description {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags Grid */
|
||||||
|
.tags-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-card:hover {
|
||||||
|
border-color: hsl(var(--primary) / 0.3);
|
||||||
|
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-color {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-name {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-card:hover .tag-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.delete:hover {
|
||||||
|
background: hsl(0 84% 60% / 0.1);
|
||||||
|
color: hsl(0 84% 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Count */
|
||||||
|
.tags-count {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: hsl(var(--background));
|
||||||
|
border-radius: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1.5px solid hsl(var(--border));
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color Picker */
|
||||||
|
.color-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-option.selected {
|
||||||
|
border-color: hsl(var(--foreground));
|
||||||
|
box-shadow: 0 0 0 2px hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag Preview */
|
||||||
|
.tag-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-spinner {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-top-color: currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icons */
|
||||||
|
.icon {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-sm {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue