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:
Till-JS 2025-12-09 18:00:55 +01:00
parent 76f573fb08
commit 4e5d12aa53
20 changed files with 4127 additions and 642 deletions

View file

@ -208,7 +208,7 @@ S3_BUCKET=contacts-photos
# Get credentials from https://console.cloud.google.com/apis/credentials
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
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)

View file

@ -71,4 +71,43 @@ export class TagController {
await this.tagService.delete(id, user.userId);
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 };
}
}

View file

@ -210,29 +210,45 @@ export const groupsApi = {
// Tags API
export const tagsApi = {
async list() {
async list(): Promise<{ tags: ContactTag[] }> {
return fetchWithAuth('/tags');
},
async create(data: { name: string; color?: string }) {
async create(data: { name: string; color?: string }): Promise<{ tag: ContactTag }> {
return fetchWithAuth('/tags', {
method: 'POST',
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}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
},
async delete(id: string) {
async delete(id: string): Promise<{ success: boolean }> {
return fetchWithAuth(`/tags/${id}`, {
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

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { contactsApi, photoApi, type Contact } from '$lib/api/contacts';
import ContactNotes from './ContactNotes.svelte';
interface Props {
contactId: string;
@ -848,6 +849,9 @@
</div>
</section>
{/if}
<!-- Contact Notes (separate from contact.notes field) -->
<ContactNotes {contactId} />
</div>
{/if}
{/if}

View file

@ -1,10 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { _ } from 'svelte-i18n';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
import { goto } from '$app/navigation';
import ExportModal from '$lib/components/export/ExportModal.svelte';
import ViewModeToggle from '$lib/components/ViewModeToggle.svelte';
import SortToggle, { type SortField } from '$lib/components/SortToggle.svelte';
import FilterBar, {
@ -20,7 +19,11 @@
let searchQuery = $state('');
let sortField = $state<SortField>('lastName');
let searchTimeout: ReturnType<typeof setTimeout>;
let showExportModal = $state(false);
// Infinite scroll
let scrollContainer: HTMLDivElement;
let intersectionObserver: IntersectionObserver | null = null;
let loadMoreTrigger: HTMLDivElement;
// Filter state
let selectedGroupId = $state<string | null>(null);
@ -28,6 +31,9 @@
let birthdayFilter = $state<BirthdayFilter>('all');
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
let selectionMode = $state(false);
let selectedIds = $state<Set<string>>(new Set());
@ -78,7 +84,9 @@
let result = [...contactsStore.contacts];
// 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);
} else if (contactFilter === 'hasEmail') {
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 () => {
// Only load if not already loaded
if (contactsStore.contacts.length === 0) {
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>
@ -248,22 +296,6 @@
</svg>
<span class="hidden sm:inline">{selectionMode ? 'Fertig' : 'Auswählen'}</span>
</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">
<span>+</span>
<span>{$_('contacts.new')}</span>
@ -372,6 +404,32 @@
/>
</svg>
</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
contacts={contactsStore.contacts}
{selectedGroupId}
@ -444,17 +502,26 @@
/>
{/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 -->
<p class="text-sm text-muted-foreground text-center">
{contactsStore.total}
{contactsStore.contacts.length} / {contactsStore.total}
{contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
</p>
{/if}
</div>
<!-- Export Modal -->
<ExportModal isOpen={showExportModal} onClose={() => (showExportModal = false)} />
<style>
.batch-actions-bar {
display: flex;
@ -496,4 +563,86 @@
background: hsl(var(--color-error) / 0.15);
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>

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

View file

@ -45,11 +45,11 @@
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 count = 0;
if (selectedGroupId) count++;
if (contactFilter !== 'all') count++;
if (contactFilter !== 'all' && contactFilter !== 'favorites') count++;
if (birthdayFilter !== 'all') count++;
if (selectedCompany) count++;
return count;
@ -68,7 +68,10 @@
function clearAllFilters() {
onGroupChange(null);
onContactFilterChange('all');
// Keep favorites filter if active (controlled by separate quick button)
if (contactFilter !== 'favorites') {
onContactFilterChange('all');
}
onBirthdayFilterChange('all');
onCompanyChange(null);
}
@ -120,7 +123,7 @@
</button>
{/if}
{/if}
{#if contactFilter !== 'all'}
{#if contactFilter !== 'all' && contactFilter !== 'favorites'}
<button type="button" class="filter-pill" onclick={() => onContactFilterChange('all')}>
{$_(`filters.contact.${contactFilter}`)}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View file

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

View file

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

View file

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

View file

@ -31,7 +31,7 @@
try {
await googleApi.handleCallback(code);
// Remove code from URL
goto('/import?tab=google', { replaceState: true });
goto('/data?tab=import&source=google', { replaceState: true });
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to connect';
}

View file

@ -4,11 +4,13 @@
},
"common": {
"back": "Zurück",
"cancel": "Abbrechen"
"cancel": "Abbrechen",
"loadingMore": "Lade weitere..."
},
"nav": {
"contacts": "Kontakte",
"groups": "Gruppen",
"tags": "Tags",
"favorites": "Favoriten",
"archive": "Archiv",
"search": "Suche",
@ -143,6 +145,7 @@
"contactInfo": "Kontaktinfo",
"contact": {
"all": "Alle Kontakte",
"favorites": "Favoriten",
"hasPhone": "Mit Telefon",
"hasEmail": "Mit E-Mail",
"incomplete": "Unvollständig"
@ -166,5 +169,34 @@
"includeArchived": "Archivierte Kontakte einschließen",
"exporting": "Exportiere...",
"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"
}
}

View file

@ -4,11 +4,13 @@
},
"common": {
"back": "Back",
"cancel": "Cancel"
"cancel": "Cancel",
"loadingMore": "Loading more..."
},
"nav": {
"contacts": "Contacts",
"groups": "Groups",
"tags": "Tags",
"favorites": "Favorites",
"archive": "Archive",
"search": "Search",
@ -143,6 +145,7 @@
"contactInfo": "Contact info",
"contact": {
"all": "All contacts",
"favorites": "Favorites",
"hasPhone": "With phone",
"hasEmail": "With email",
"incomplete": "Incomplete"
@ -166,5 +169,34 @@
"includeArchived": "Include archived contacts",
"exporting": "Exporting...",
"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"
}
}

View file

@ -5,13 +5,19 @@
import { contactsApi } from '$lib/api/contacts';
import type { Contact, ContactFilters } from '$lib/api/contacts';
// Default page size for pagination
const DEFAULT_PAGE_SIZE = 50;
// State
let contacts = $state<Contact[]>([]);
let selectedContact = $state<Contact | null>(null);
let loading = $state(false);
let loadingMore = $state(false);
let error = $state<string | null>(null);
let total = $state(0);
let filters = $state<ContactFilters>({});
let hasMore = $state(true);
let currentOffset = $state(0);
export const contactsStore = {
// Getters
@ -24,6 +30,9 @@ export const contactsStore = {
get loading() {
return loading;
},
get loadingMore() {
return loadingMore;
},
get error() {
return error;
},
@ -33,9 +42,12 @@ export const contactsStore = {
get 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) {
if (newFilters) {
@ -44,11 +56,18 @@ export const contactsStore = {
loading = true;
error = null;
currentOffset = 0;
try {
const result = await contactsApi.list(filters);
const result = await contactsApi.list({
...filters,
limit: DEFAULT_PAGE_SIZE,
offset: 0,
});
contacts = result.contacts;
total = result.total;
hasMore = contacts.length < total;
currentOffset = contacts.length;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load contacts';
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
*/

View file

@ -77,6 +77,7 @@
const navItems: PillNavItem[] = [
{ href: '/', label: 'Kontakte', icon: 'users' },
{ href: '/groups', label: 'Gruppen', icon: 'folder' },
{ href: '/tags', label: 'Tags', icon: 'tag' },
{ href: '/favorites', label: 'Favoriten', icon: 'heart' },
{ href: '/archive', label: 'Archiv', icon: 'archive' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },

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

View file

@ -1,16 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { contactsApi } 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';
type ViewMode = 'cards' | 'list' | 'alphabet';
let loading = $state(true);
let contacts = $state<Contact[]>([]);
let error = $state<string | null>(null);
let searchQuery = $state('');
let viewMode = $state<ViewMode>('cards');
const filteredContacts = $derived(() => {
const filteredContacts = $derived.by(() => {
if (!searchQuery.trim()) return contacts;
const query = searchQuery.toLowerCase();
return contacts.filter((c) => {
@ -18,7 +25,9 @@
return (
name.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';
}
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) {
goto(`/contacts/${id}`);
}
async function handleToggleFavorite(e: MouseEvent, contact: Contact) {
async function handleToggleFavorite(e: MouseEvent, id: string) {
e.stopPropagation();
try {
await contactsApi.toggleFavorite(contact.id);
await contactsApi.toggleFavorite(id);
// 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) {
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>
<svelte:head>
<title>Favoriten - Contacts</title>
</svelte:head>
<div class="page-container">
<!-- Header -->
<header class="header">
<a href="/" class="back-button" aria-label="Zurück">
<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">Favoriten</h1>
<div class="title-icon">
<svg class="icon" fill="currentColor" viewBox="0 0 24 24">
<div class="favorites-page">
<!-- Hero Header -->
<div class="hero-header">
<div class="hero-content">
<div class="hero-icon">
<svg 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>
</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
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>
</div>
</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"
<input
type="text"
placeholder="Favoriten durchsuchen..."
bind:value={searchQuery}
class="search-input"
/>
</svg>
<input
type="text"
placeholder="Favoriten durchsuchen..."
bind:value={searchQuery}
class="search-input"
/>
{#if searchQuery}
<button class="search-clear" onclick={() => (searchQuery = '')}>
<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="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>
{#if error}
<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
stroke-linecap="round"
stroke-linejoin="round"
@ -126,6 +273,7 @@
{#if loading}
<div class="loading-container">
<div class="spinner"></div>
<p class="loading-text">Favoriten werden geladen...</p>
</div>
{:else if contacts.length === 0}
<div class="empty-state">
@ -134,17 +282,18 @@
<path
stroke-linecap="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"
/>
</svg>
</div>
<h2 class="empty-title">Keine Favoriten</h2>
<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>
<a href="/" class="btn btn-primary">
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<a href="/" class="btn-primary">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="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"
/>
</svg>
Zu Kontakten
Zu allen Kontakten
</a>
</div>
{:else if filteredContacts().length === 0}
{:else if filteredContacts.length === 0}
<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">
<path
stroke-linecap="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"
/>
</svg>
</div>
<h2 class="empty-title">Keine Ergebnisse</h2>
<p class="empty-description">Keine Favoriten gefunden für "{searchQuery}"</p>
<button class="btn-secondary" onclick={() => (searchQuery = '')}>Suche zurücksetzen</button>
</div>
{:else}
<div class="contacts-list">
{#each filteredContacts() as contact (contact.id)}
<div
role="button"
tabindex="0"
onclick={() => handleContactClick(contact.id)}
onkeydown={(e) => e.key === 'Enter' && handleContactClick(contact.id)}
class="contact-card"
>
<!-- Avatar -->
<div class="avatar">
{#if contact.photoUrl}
<img src={contact.photoUrl} alt={getDisplayName(contact)} />
{:else}
{getInitials(contact)}
{/if}
</div>
<!-- Contact Info -->
<div class="contact-info">
<h3 class="contact-name">{getDisplayName(contact)}</h3>
{#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}
<!-- View Content -->
<div class="view-content">
{#if viewMode === 'cards'}
<FavoriteCardView
contacts={filteredContacts}
onContactClick={handleContactClick}
onToggleFavorite={handleToggleFavorite}
/>
{:else if viewMode === 'list'}
<FavoriteListView
contacts={filteredContacts}
onContactClick={handleContactClick}
onToggleFavorite={handleToggleFavorite}
/>
{:else}
<FavoriteAlphabetView
contacts={filteredContacts}
onContactClick={handleContactClick}
onToggleFavorite={handleToggleFavorite}
/>
{/if}
</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}
</div>
<style>
.page-container {
max-width: 640px;
.favorites-page {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
/* Header */
.header {
/* Hero 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;
align-items: center;
gap: 1rem;
padding: 1rem 0;
position: sticky;
top: 0;
background: hsl(var(--color-background));
z-index: 10;
margin-bottom: 0.5rem;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.back-button {
.hero-icon {
display: flex;
align-items: 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;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
transition: all 0.2s ease;
}
.back-button:hover {
background: hsl(var(--color-surface-hover));
transform: translateX(-2px);
}
.title {
.hero-text {
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;
align-items: center;
justify-content: center;
width: 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;
}
/* 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 {
position: relative;
margin-bottom: 1.5rem;
flex: 1;
}
.search-icon {
@ -288,29 +489,84 @@
transform: translateY(-50%);
width: 1.25rem;
height: 1.25rem;
color: hsl(var(--color-muted-foreground));
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(--color-border));
border-radius: 0.75rem;
background: hsl(var(--color-input));
color: hsl(var(--color-foreground));
padding: 0.875rem 2.5rem 0.875rem 3rem;
border: 1.5px solid hsl(var(--border));
border-radius: 0.875rem;
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(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
border-color: #ef4444;
box-shadow: 0 0 0 3px hsl(0 84% 60% / 0.1);
}
.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 */
@ -318,11 +574,11 @@
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: hsl(var(--color-error) / 0.1);
border: 1px solid hsl(var(--color-error) / 0.3);
border-radius: 0.75rem;
color: hsl(var(--color-error));
padding: 1rem 1.25rem;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.3);
border-radius: 0.875rem;
color: hsl(var(--destructive));
margin-bottom: 1.5rem;
}
@ -344,19 +600,26 @@
/* Loading */
.loading-container {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
padding: 4rem 0;
gap: 1rem;
}
.spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid hsl(var(--color-muted));
border-top-color: hsl(var(--color-primary));
width: 3rem;
height: 3rem;
border: 3px solid hsl(var(--muted));
border-top-color: #ef4444;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
color: hsl(var(--muted-foreground));
font-size: 0.9375rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
@ -368,15 +631,15 @@
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1rem;
padding: 4rem 1rem;
text-align: center;
}
.empty-icon {
width: 5rem;
height: 5rem;
width: 6rem;
height: 6rem;
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;
display: flex;
align-items: center;
@ -385,168 +648,117 @@
}
.empty-icon svg {
width: 2.5rem;
height: 2.5rem;
width: 3rem;
height: 3rem;
}
.empty-icon-search {
background: linear-gradient(135deg, hsl(var(--muted)), hsl(var(--muted) / 0.5));
color: hsl(var(--muted-foreground));
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--color-foreground));
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
margin-bottom: 0.5rem;
}
.empty-description {
color: hsl(var(--color-muted-foreground));
margin-bottom: 1.5rem;
max-width: 280px;
color: hsl(var(--muted-foreground));
margin-bottom: 1.75rem;
max-width: 320px;
line-height: 1.6;
}
/* Contacts List */
.contacts-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.contact-card {
display: flex;
/* Buttons */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1.75rem;
background: linear-gradient(135deg, #ef4444, #ec4899);
color: white;
border-radius: 0.875rem;
font-weight: 600;
font-size: 0.9375rem;
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 12px hsl(0 84% 60% / 0.3);
}
.contact-card:hover {
border-color: hsl(var(--color-primary) / 0.3);
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(var(--color-foreground) / 0.05);
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px hsl(0 84% 60% / 0.4);
}
.avatar {
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 {
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: hsl(var(--muted));
color: hsl(var(--foreground));
border: none;
border-radius: 0.75rem;
font-weight: 600;
font-weight: 500;
font-size: 0.9375rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
text-decoration: none;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.3);
.btn-secondary:hover {
background: hsl(var(--muted-foreground) / 0.15);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px hsl(var(--color-primary) / 0.4);
/* View Content */
.view-content {
margin-bottom: 2rem;
}
/* Icons */
.icon {
width: 1.25rem;
height: 1.25rem;
/* Footer */
.footer-count {
text-align: center;
font-size: 0.9375rem;
color: hsl(var(--muted-foreground));
padding: 1rem 0;
}
.icon-sm {
width: 1rem;
height: 1rem;
/* Responsive */
@media (max-width: 768px) {
.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>

View file

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

View file

@ -53,12 +53,6 @@
{ 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 = [
{ value: 'strict', label: 'Streng' },
{ value: 'normal', label: 'Normal' },
@ -195,8 +189,7 @@
description="Standard-Sortierung der Kontakte"
options={sortByOptions}
value={contactsSettings.sortBy}
onchange={(v: string | number | null) =>
contactsSettings.set('sortBy', v as ContactSortBy)}
onchange={(v: string | number | null) => contactsSettings.set('sortBy', v as ContactSortBy)}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -385,7 +378,7 @@
</SettingsSection>
<!-- Import/Export Section -->
<SettingsSection title="Import & Export">
<SettingsSection title="Daten">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -398,13 +391,10 @@
{/snippet}
<SettingsCard>
<SettingsSelect
label="Standard-Exportformat"
description="Bevorzugtes Format für Kontakt-Export"
options={exportFormatOptions}
value={contactsSettings.defaultExportFormat}
onchange={(v: string | number | null) =>
contactsSettings.set('defaultExportFormat', v as 'vcf' | 'csv' | 'json')}
<SettingsRow
label="Kontakte importieren"
description="Aus Datei oder Google importieren"
href="/data?tab=import"
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -412,35 +402,16 @@
stroke-linecap="round"
stroke-linejoin="round"
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>
{/snippet}
</SettingsSelect>
</SettingsRow>
<SettingsToggle
label="Notizen exportieren"
description="Notizen beim Export mit einschließen"
isOn={contactsSettings.includeNotesInExport}
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)}
<SettingsRow
label="Kontakte exportieren"
description="Als vCard oder CSV herunterladen"
href="/data?tab=export"
border={false}
>
{#snippet icon()}
@ -449,11 +420,11 @@
stroke-linecap="round"
stroke-linejoin="round"
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>
{/snippet}
</SettingsToggle>
</SettingsRow>
</SettingsCard>
</SettingsSection>

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