mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
feat(contacts): add enhanced favorites page with multiple view modes
- Add quick favorites filter button on homepage with badge count - Create dedicated favorites page with hero header and stats cards - Implement three view modes for favorites: cards, list, alphabet - Cards: Large 120px avatars with gradient backgrounds, full contact details - List: 72px avatars with detail chips and hover actions - Alphabet: Grouped by letter with quick-jump navigation - Fix layout jump when favorites filter is active (exclude from FilterBar count) - Add tags management feature with CRUD operations - Reorganize import page to /data route - Add infinite scroll to contacts list - Add contact notes feature - Persist favorites view mode in localStorage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
76f573fb08
commit
4e5d12aa53
20 changed files with 4127 additions and 642 deletions
|
|
@ -208,7 +208,7 @@ S3_BUCKET=contacts-photos
|
|||
# Get credentials from https://console.cloud.google.com/apis/credentials
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
598
apps/contacts/apps/web/src/lib/components/ContactNotes.svelte
Normal file
598
apps/contacts/apps/web/src/lib/components/ContactNotes.svelte
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { notesApi, type ContactNote } from '$lib/api/contacts';
|
||||
|
||||
interface Props {
|
||||
contactId: string;
|
||||
}
|
||||
|
||||
let { contactId }: Props = $props();
|
||||
|
||||
let notes = $state<ContactNote[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// New note state
|
||||
let newNoteContent = $state('');
|
||||
let addingNote = $state(false);
|
||||
let showAddForm = $state(false);
|
||||
|
||||
// Edit state
|
||||
let editingNoteId = $state<string | null>(null);
|
||||
let editContent = $state('');
|
||||
let savingEdit = $state(false);
|
||||
|
||||
const sortedNotes = $derived.by(() => {
|
||||
// Pinned notes first, then by date
|
||||
return [...notes].sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
});
|
||||
|
||||
async function loadNotes() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const response = await notesApi.list(contactId);
|
||||
notes = response.notes || [];
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $_('messages.error');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddNote() {
|
||||
if (!newNoteContent.trim()) return;
|
||||
|
||||
addingNote = true;
|
||||
error = null;
|
||||
try {
|
||||
const response = await notesApi.create(contactId, {
|
||||
content: newNoteContent.trim(),
|
||||
});
|
||||
notes = [...notes, response.note];
|
||||
newNoteContent = '';
|
||||
showAddForm = false;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $_('messages.error');
|
||||
} finally {
|
||||
addingNote = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEditing(note: ContactNote) {
|
||||
editingNoteId = note.id;
|
||||
editContent = note.content;
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
editingNoteId = null;
|
||||
editContent = '';
|
||||
}
|
||||
|
||||
async function handleSaveEdit(noteId: string) {
|
||||
if (!editContent.trim()) return;
|
||||
|
||||
savingEdit = true;
|
||||
error = null;
|
||||
try {
|
||||
const response = await notesApi.update(noteId, {
|
||||
content: editContent.trim(),
|
||||
});
|
||||
notes = notes.map((n) => (n.id === noteId ? response.note : n));
|
||||
editingNoteId = null;
|
||||
editContent = '';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $_('messages.error');
|
||||
} finally {
|
||||
savingEdit = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(noteId: string) {
|
||||
if (!confirm($_('notes.confirmDelete'))) return;
|
||||
|
||||
try {
|
||||
await notesApi.delete(noteId);
|
||||
notes = notes.filter((n) => n.id !== noteId);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $_('messages.error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePin(noteId: string) {
|
||||
try {
|
||||
const response = await notesApi.togglePin(noteId);
|
||||
notes = notes.map((n) => (n.id === noteId ? response.note : n));
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $_('messages.error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (diffDays === 1) {
|
||||
return $_('notes.yesterday');
|
||||
} else if (diffDays < 7) {
|
||||
return date.toLocaleDateString('de-DE', { weekday: 'short' });
|
||||
} else {
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' });
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadNotes);
|
||||
</script>
|
||||
|
||||
<section class="notes-section">
|
||||
<div class="section-header">
|
||||
<div class="section-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="section-title">{$_('notes.title')}</h3>
|
||||
<button
|
||||
onclick={() => (showAddForm = !showAddForm)}
|
||||
class="add-note-btn"
|
||||
aria-label={$_('notes.add')}
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add Note Form -->
|
||||
{#if showAddForm}
|
||||
<div class="add-note-form">
|
||||
<textarea
|
||||
bind:value={newNoteContent}
|
||||
placeholder={$_('notes.placeholder')}
|
||||
class="note-input"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
onclick={() => {
|
||||
showAddForm = false;
|
||||
newNoteContent = '';
|
||||
}}
|
||||
class="btn-cancel"
|
||||
disabled={addingNote}
|
||||
>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleAddNote}
|
||||
class="btn-save"
|
||||
disabled={addingNote || !newNoteContent.trim()}
|
||||
>
|
||||
{#if addingNote}
|
||||
<span class="spinner"></span>
|
||||
{/if}
|
||||
{$_('notes.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notes List -->
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<span class="spinner"></span>
|
||||
</div>
|
||||
{:else if notes.length === 0 && !showAddForm}
|
||||
<div class="empty-notes">
|
||||
<p>{$_('notes.empty')}</p>
|
||||
<button onclick={() => (showAddForm = true)} class="btn-add-first">
|
||||
{$_('notes.addFirst')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="notes-list">
|
||||
{#each sortedNotes as note (note.id)}
|
||||
<div class="note-item" class:pinned={note.isPinned}>
|
||||
{#if editingNoteId === note.id}
|
||||
<!-- Edit Mode -->
|
||||
<textarea bind:value={editContent} class="note-input edit-input" rows="3"></textarea>
|
||||
<div class="form-actions">
|
||||
<button onclick={cancelEditing} class="btn-cancel" disabled={savingEdit}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleSaveEdit(note.id)}
|
||||
class="btn-save"
|
||||
disabled={savingEdit || !editContent.trim()}
|
||||
>
|
||||
{#if savingEdit}
|
||||
<span class="spinner"></span>
|
||||
{/if}
|
||||
{$_('actions.save')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- View Mode -->
|
||||
<div class="note-content">
|
||||
{#if note.isPinned}
|
||||
<span class="pin-badge">
|
||||
<svg fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z" />
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
<p class="note-text">{note.content}</p>
|
||||
<span class="note-date">{formatDate(note.createdAt)}</span>
|
||||
</div>
|
||||
<div class="note-actions">
|
||||
<button
|
||||
onclick={() => handleTogglePin(note.id)}
|
||||
class="note-action"
|
||||
class:active={note.isPinned}
|
||||
aria-label={note.isPinned ? $_('notes.unpin') : $_('notes.pin')}
|
||||
title={note.isPinned ? $_('notes.unpin') : $_('notes.pin')}
|
||||
>
|
||||
<svg
|
||||
fill={note.isPinned ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => startEditing(note)}
|
||||
class="note-action"
|
||||
aria-label={$_('actions.edit')}
|
||||
title={$_('actions.edit')}
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDelete(note.id)}
|
||||
class="note-action delete"
|
||||
aria-label={$_('actions.delete')}
|
||||
title={$_('actions.delete')}
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.notes-section {
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.875rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding-bottom: 0.625rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section-icon svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.add-note-btn {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-note-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.add-note-btn svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: hsl(var(--color-error) / 0.1);
|
||||
border-radius: 0.5rem;
|
||||
color: hsl(var(--color-error));
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Add Note Form */
|
||||
.add-note-form {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.note-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1.5px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-input));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
resize: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.note-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.btn-cancel:hover:not(:disabled) {
|
||||
background: hsl(var(--color-surface-hover));
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
.btn-save:hover:not(:disabled) {
|
||||
box-shadow: 0 2px 8px hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.btn-cancel:disabled,
|
||||
.btn-save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Loading & Empty */
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid hsl(var(--color-muted));
|
||||
border-top-color: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-notes {
|
||||
text-align: center;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.empty-notes p {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-add-first {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
border: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-add-first:hover {
|
||||
background: hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
|
||||
/* Notes List */
|
||||
.notes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
}
|
||||
|
||||
.note-item.pinned {
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.2);
|
||||
}
|
||||
|
||||
.note-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.pin-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: hsl(var(--color-primary));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.pin-badge svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.note-date {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.note-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.note-item:hover .note-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.note-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.note-action:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.note-action.active {
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.note-action.delete:hover {
|
||||
background: hsl(var(--color-error) / 0.1);
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.note-action svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -45,11 +45,11 @@
|
|||
return Array.from(companySet).sort((a, b) => a.localeCompare(b, 'de'));
|
||||
});
|
||||
|
||||
// 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">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,494 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { Contact } from '$lib/api/contacts';
|
||||
|
||||
interface Props {
|
||||
contacts: Contact[];
|
||||
onContactClick: (id: string) => void;
|
||||
onToggleFavorite: (e: MouseEvent, id: string) => void;
|
||||
}
|
||||
|
||||
let { contacts, onContactClick, onToggleFavorite }: Props = $props();
|
||||
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||
|
||||
function getInitials(contact: Contact) {
|
||||
const first = contact.firstName?.[0] || '';
|
||||
const last = contact.lastName?.[0] || '';
|
||||
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
function getDisplayName(contact: Contact) {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
if (contact.firstName || contact.lastName) {
|
||||
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return contact.email || 'Unbekannt';
|
||||
}
|
||||
|
||||
function getFirstLetter(contact: Contact): string {
|
||||
const name =
|
||||
contact.lastName || contact.firstName || contact.displayName || contact.email || '';
|
||||
const letter = name[0]?.toUpperCase() || '#';
|
||||
return /[A-Z]/.test(letter) ? letter : '#';
|
||||
}
|
||||
|
||||
function formatPhone(phone: string | null | undefined) {
|
||||
if (!phone) return null;
|
||||
return phone.replace(/\s/g, '');
|
||||
}
|
||||
|
||||
// Group contacts by first letter
|
||||
let groupedContacts = $derived.by(() => {
|
||||
const groups: Record<string, Contact[]> = {};
|
||||
|
||||
// Sort contacts by last name first
|
||||
const sorted = [...contacts].sort((a, b) => {
|
||||
const aName = (a.lastName || a.firstName || a.displayName || a.email || '').toLowerCase();
|
||||
const bName = (b.lastName || b.firstName || b.displayName || b.email || '').toLowerCase();
|
||||
return aName.localeCompare(bName, 'de');
|
||||
});
|
||||
|
||||
for (const contact of sorted) {
|
||||
const letter = getFirstLetter(contact);
|
||||
if (!groups[letter]) {
|
||||
groups[letter] = [];
|
||||
}
|
||||
groups[letter].push(contact);
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
let availableLetters = $derived(Object.keys(groupedContacts).sort());
|
||||
|
||||
function scrollToLetter(letter: string) {
|
||||
const element = document.getElementById(`fav-section-${letter}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="alphabet-view">
|
||||
<!-- Contacts grouped by letter -->
|
||||
<div class="alphabet-sections">
|
||||
{#each availableLetters as letter}
|
||||
<div id="fav-section-{letter}" class="alphabet-section">
|
||||
<!-- Section Header -->
|
||||
<div class="section-header">
|
||||
<span class="section-letter">{letter}</span>
|
||||
<span class="section-count">{groupedContacts[letter].length}</span>
|
||||
</div>
|
||||
|
||||
<!-- Contacts in this section -->
|
||||
<div class="section-contacts">
|
||||
{#each groupedContacts[letter] as contact (contact.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onContactClick(contact.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
|
||||
class="alphabet-card"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="card-avatar">
|
||||
{#if contact.photoUrl}
|
||||
<img
|
||||
src={contact.photoUrl}
|
||||
alt={getDisplayName(contact)}
|
||||
class="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
{getInitials(contact)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="card-content">
|
||||
<h3 class="card-name">{getDisplayName(contact)}</h3>
|
||||
<div class="card-meta">
|
||||
{#if contact.jobTitle && contact.company}
|
||||
<span>{contact.jobTitle} @ {contact.company}</span>
|
||||
{:else if contact.company}
|
||||
<span>{contact.company}</span>
|
||||
{:else if contact.email}
|
||||
<span>{contact.email}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if contact.phone || contact.mobile || contact.email}
|
||||
<div class="card-chips">
|
||||
{#if contact.phone || contact.mobile}
|
||||
<span class="chip">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
{contact.mobile || contact.phone}
|
||||
</span>
|
||||
{/if}
|
||||
{#if contact.email}
|
||||
<span class="chip">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{contact.email}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card-actions">
|
||||
{#if contact.phone || contact.mobile}
|
||||
<a
|
||||
href="tel:{formatPhone(contact.mobile || contact.phone)}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="action-btn action-call"
|
||||
title="Anrufen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
{#if contact.email}
|
||||
<a
|
||||
href="mailto:{contact.email}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="action-btn action-email"
|
||||
title="E-Mail senden"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
onclick={(e) => onToggleFavorite(e, contact.id)}
|
||||
class="action-btn action-heart"
|
||||
title="Aus Favoriten entfernen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Alphabet Quick Jump -->
|
||||
<div class="alphabet-nav">
|
||||
{#each alphabet as letter}
|
||||
<button
|
||||
type="button"
|
||||
class="alphabet-nav-btn"
|
||||
class:active={availableLetters.includes(letter)}
|
||||
class:disabled={!availableLetters.includes(letter)}
|
||||
onclick={() => availableLetters.includes(letter) && scrollToLetter(letter)}
|
||||
disabled={!availableLetters.includes(letter)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
{/each}
|
||||
{#if availableLetters.includes('#')}
|
||||
<button type="button" class="alphabet-nav-btn active" onclick={() => scrollToLetter('#')}>
|
||||
#
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.alphabet-view {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.alphabet-sections {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alphabet-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
z-index: 10;
|
||||
background: linear-gradient(135deg, hsl(0 84% 60% / 0.1), hsl(0 84% 60% / 0.05));
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(0 84% 60% / 0.2);
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05);
|
||||
}
|
||||
|
||||
.section-letter {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 800;
|
||||
color: #ef4444;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.section-contacts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.alphabet-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.alphabet-card:hover {
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
background: hsl(var(--accent));
|
||||
box-shadow: 0 4px 12px -2px hsl(var(--foreground) / 0.08);
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
min-width: 64px;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.375rem;
|
||||
box-shadow: 0 4px 12px -2px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.0625rem;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.alphabet-card:hover .card-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-call {
|
||||
background: hsl(142 76% 36% / 0.1);
|
||||
color: hsl(142 76% 36%);
|
||||
}
|
||||
|
||||
.action-call:hover {
|
||||
background: hsl(142 76% 36%);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.action-email {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.action-email:hover {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.action-heart {
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.action-heart:hover {
|
||||
background: hsl(0 84% 60% / 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Alphabet Navigation */
|
||||
.alphabet-nav {
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
height: fit-content;
|
||||
background: hsl(var(--background) / 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border-radius: var(--radius-lg, 0.75rem);
|
||||
box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05);
|
||||
}
|
||||
|
||||
.alphabet-nav-btn {
|
||||
width: 1.75rem;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.alphabet-nav-btn.active {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.alphabet-nav-btn.active:hover {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alphabet-nav-btn.disabled {
|
||||
color: hsl(var(--muted-foreground) / 0.3);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.alphabet-view {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.alphabet-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.alphabet-nav-btn {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.alphabet-sections {
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-chips {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { Contact } from '$lib/api/contacts';
|
||||
|
||||
interface Props {
|
||||
contacts: Contact[];
|
||||
onContactClick: (id: string) => void;
|
||||
onToggleFavorite: (e: MouseEvent, id: string) => void;
|
||||
}
|
||||
|
||||
let { contacts, onContactClick, onToggleFavorite }: Props = $props();
|
||||
|
||||
function getInitials(contact: Contact) {
|
||||
const first = contact.firstName?.[0] || '';
|
||||
const last = contact.lastName?.[0] || '';
|
||||
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
function getDisplayName(contact: Contact) {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
if (contact.firstName || contact.lastName) {
|
||||
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return contact.email || 'Unbekannt';
|
||||
}
|
||||
|
||||
// Generate a consistent gradient based on contact name
|
||||
function getGradient(contact: Contact) {
|
||||
const name = getDisplayName(contact);
|
||||
const hash = name.split('').reduce((acc, char) => char.charCodeAt(0) + acc, 0);
|
||||
const gradients = [
|
||||
'from-rose-500 to-pink-600',
|
||||
'from-violet-500 to-purple-600',
|
||||
'from-blue-500 to-indigo-600',
|
||||
'from-cyan-500 to-teal-600',
|
||||
'from-emerald-500 to-green-600',
|
||||
'from-amber-500 to-orange-600',
|
||||
'from-red-500 to-rose-600',
|
||||
'from-fuchsia-500 to-pink-600',
|
||||
];
|
||||
return gradients[hash % gradients.length];
|
||||
}
|
||||
|
||||
function formatPhone(phone: string | null | undefined) {
|
||||
if (!phone) return null;
|
||||
return phone.replace(/\s/g, '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="favorites-grid">
|
||||
{#each contacts as contact (contact.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onContactClick(contact.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
|
||||
class="favorite-card"
|
||||
>
|
||||
<!-- Decorative background gradient -->
|
||||
<div class="card-bg bg-gradient-to-br {getGradient(contact)}"></div>
|
||||
|
||||
<!-- Favorite badge -->
|
||||
<button
|
||||
onclick={(e) => onToggleFavorite(e, contact.id)}
|
||||
class="favorite-badge"
|
||||
title="Aus Favoriten entfernen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Large Avatar -->
|
||||
<div class="card-avatar bg-gradient-to-br {getGradient(contact)}">
|
||||
{#if contact.photoUrl}
|
||||
<img
|
||||
src={contact.photoUrl}
|
||||
alt={getDisplayName(contact)}
|
||||
class="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-white font-bold text-3xl">{getInitials(contact)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="card-content">
|
||||
<h3 class="card-name">{getDisplayName(contact)}</h3>
|
||||
{#if contact.jobTitle}
|
||||
<p class="card-job">{contact.jobTitle}</p>
|
||||
{/if}
|
||||
{#if contact.company}
|
||||
<p class="card-company">{contact.company}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Contact Details -->
|
||||
<div class="card-details">
|
||||
{#if contact.email}
|
||||
<div class="detail-row">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="detail-text">{contact.email}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if contact.phone || contact.mobile}
|
||||
<div class="detail-row">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="detail-text">{contact.mobile || contact.phone}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if contact.birthday}
|
||||
<div class="detail-row">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 15.546c-.523 0-1.046.151-1.5.454a2.704 2.704 0 01-3 0 2.704 2.704 0 00-3 0 2.704 2.704 0 01-3 0 2.704 2.704 0 00-3 0 2.704 2.704 0 01-3 0 2.701 2.701 0 00-1.5-.454M9 6v2m3-2v2m3-2v2M9 3h.01M12 3h.01M15 3h.01M21 21v-7a2 2 0 00-2-2H5a2 2 0 00-2 2v7h18zm-3-9v-2a2 2 0 00-2-2H8a2 2 0 00-2 2v2h12z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="detail-text"
|
||||
>{new Date(contact.birthday).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
})}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card-actions">
|
||||
{#if contact.phone || contact.mobile}
|
||||
<a
|
||||
href="tel:{formatPhone(contact.mobile || contact.phone)}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="action-btn action-call"
|
||||
title="Anrufen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
{#if contact.email}
|
||||
<a
|
||||
href="mailto:{contact.email}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="action-btn action-email"
|
||||
title="E-Mail senden"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.favorites-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.favorites-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.favorites-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem 1.5rem 1.5rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.favorite-card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow:
|
||||
0 20px 40px -12px hsl(var(--foreground) / 0.15),
|
||||
0 4px 12px -2px hsl(var(--foreground) / 0.1);
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
.card-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 80px;
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.favorite-badge {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
background: hsl(var(--background) / 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.favorite-badge:hover {
|
||||
transform: scale(1.15);
|
||||
background: hsl(0 84% 60% / 0.15);
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow:
|
||||
0 8px 24px -4px hsl(var(--foreground) / 0.2),
|
||||
0 0 0 4px hsl(var(--background));
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-job {
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.card-company {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground) / 0.8);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid hsl(var(--border) / 0.5);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.detail-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid hsl(var(--border) / 0.5);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-call {
|
||||
background: hsl(142 76% 36% / 0.1);
|
||||
color: hsl(142 76% 36%);
|
||||
}
|
||||
|
||||
.action-call:hover {
|
||||
background: hsl(142 76% 36%);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.action-email {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.action-email:hover {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
transform: scale(1.1);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,324 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { Contact } from '$lib/api/contacts';
|
||||
|
||||
interface Props {
|
||||
contacts: Contact[];
|
||||
onContactClick: (id: string) => void;
|
||||
onToggleFavorite: (e: MouseEvent, id: string) => void;
|
||||
}
|
||||
|
||||
let { contacts, onContactClick, onToggleFavorite }: Props = $props();
|
||||
|
||||
function getInitials(contact: Contact) {
|
||||
const first = contact.firstName?.[0] || '';
|
||||
const last = contact.lastName?.[0] || '';
|
||||
return (first + last).toUpperCase() || contact.email?.[0]?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
function getDisplayName(contact: Contact) {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
if (contact.firstName || contact.lastName) {
|
||||
return [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return contact.email || 'Unbekannt';
|
||||
}
|
||||
|
||||
function formatPhone(phone: string | null | undefined) {
|
||||
if (!phone) return null;
|
||||
return phone.replace(/\s/g, '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="favorites-list">
|
||||
{#each contacts as contact (contact.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onContactClick(contact.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
|
||||
class="favorite-row"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="row-avatar">
|
||||
{#if contact.photoUrl}
|
||||
<img
|
||||
src={contact.photoUrl}
|
||||
alt={getDisplayName(contact)}
|
||||
class="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
{getInitials(contact)}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="row-content">
|
||||
<div class="row-main">
|
||||
<h3 class="row-name">{getDisplayName(contact)}</h3>
|
||||
{#if contact.jobTitle || contact.company}
|
||||
<p class="row-subtitle">
|
||||
{[contact.jobTitle, contact.company].filter(Boolean).join(' @ ')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Contact Details -->
|
||||
<div class="row-details">
|
||||
{#if contact.email}
|
||||
<div class="detail-chip">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{contact.email}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if contact.phone || contact.mobile}
|
||||
<div class="detail-chip">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{contact.mobile || contact.phone}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if contact.birthday}
|
||||
<div class="detail-chip">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
>{new Date(contact.birthday).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="row-actions">
|
||||
{#if contact.phone || contact.mobile}
|
||||
<a
|
||||
href="tel:{formatPhone(contact.mobile || contact.phone)}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="action-btn action-call"
|
||||
title="Anrufen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
{#if contact.email}
|
||||
<a
|
||||
href="mailto:{contact.email}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="action-btn action-email"
|
||||
title="E-Mail senden"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
onclick={(e) => onToggleFavorite(e, contact.id)}
|
||||
class="action-btn action-heart"
|
||||
title="Aus Favoriten entfernen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.favorites-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.favorite-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.favorite-row:hover {
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
background: hsl(var(--accent));
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 4px 12px -2px hsl(var(--foreground) / 0.08);
|
||||
}
|
||||
|
||||
.row-avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
min-width: 72px;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
box-shadow: 0 4px 12px -2px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
.row-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-main {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.row-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.row-subtitle {
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.row-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.favorite-row:hover .row-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-call {
|
||||
background: hsl(142 76% 36% / 0.1);
|
||||
color: hsl(142 76% 36%);
|
||||
}
|
||||
|
||||
.action-call:hover {
|
||||
background: hsl(142 76% 36%);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.action-email {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.action-email:hover {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.action-heart {
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.action-heart:hover {
|
||||
background: hsl(0 84% 60% / 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.row-actions {
|
||||
opacity: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.row-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
min-width: 56px;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.row-details {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
try {
|
||||
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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
635
apps/contacts/apps/web/src/routes/(app)/data/+page.svelte
Normal file
635
apps/contacts/apps/web/src/routes/(app)/data/+page.svelte
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import FileUploader from '$lib/components/import/FileUploader.svelte';
|
||||
import ImportPreview from '$lib/components/import/ImportPreview.svelte';
|
||||
import GoogleImport from '$lib/components/import/GoogleImport.svelte';
|
||||
import { importApi, type ImportPreviewResponse, type DuplicateAction } from '$lib/api/import';
|
||||
import { exportApi, type ExportFormat } from '$lib/api/export';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import { groupsStore } from '$lib/stores/groups.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
type Tab = 'import' | 'export';
|
||||
type ImportSource = 'file' | 'google';
|
||||
type ImportStep = 'upload' | 'preview' | 'result';
|
||||
|
||||
// Get initial tab from URL
|
||||
let activeTab = $state<Tab>(($page.url.searchParams.get('tab') as Tab) || 'import');
|
||||
let importSource = $state<ImportSource>(
|
||||
($page.url.searchParams.get('source') as ImportSource) || 'file'
|
||||
);
|
||||
|
||||
// Import state
|
||||
let importStep = $state<ImportStep>('upload');
|
||||
let isLoading = $state(false);
|
||||
let isImporting = $state(false);
|
||||
let importError = $state<string | null>(null);
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let preview = $state<ImportPreviewResponse | null>(null);
|
||||
let importResult = $state<{
|
||||
imported: number;
|
||||
skipped: number;
|
||||
merged: number;
|
||||
errors: { index: number; contactName: string; error: string }[];
|
||||
} | null>(null);
|
||||
|
||||
// Export state
|
||||
let exportFormat = $state<ExportFormat>('vcard');
|
||||
let includeArchived = $state(false);
|
||||
let includeNotes = $state(true);
|
||||
let includePhotos = $state(true);
|
||||
let selectedGroupId = $state<string | null>(null);
|
||||
let onlyFavorites = $state(false);
|
||||
let isExporting = $state(false);
|
||||
let exportError = $state<string | null>(null);
|
||||
let exportSuccess = $state(false);
|
||||
|
||||
// Load groups for export filter
|
||||
$effect(() => {
|
||||
if (activeTab === 'export' && groupsStore.groups.length === 0) {
|
||||
groupsStore.loadGroups();
|
||||
}
|
||||
});
|
||||
|
||||
function setActiveTab(tab: Tab) {
|
||||
activeTab = tab;
|
||||
updateUrl();
|
||||
// Reset states
|
||||
if (tab === 'import') {
|
||||
resetImportState();
|
||||
} else {
|
||||
resetExportState();
|
||||
}
|
||||
}
|
||||
|
||||
function setImportSource(source: ImportSource) {
|
||||
importSource = source;
|
||||
updateUrl();
|
||||
resetImportState();
|
||||
}
|
||||
|
||||
function updateUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('tab', activeTab);
|
||||
if (activeTab === 'import') {
|
||||
url.searchParams.set('source', importSource);
|
||||
} else {
|
||||
url.searchParams.delete('source');
|
||||
}
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
|
||||
function resetImportState() {
|
||||
importStep = 'upload';
|
||||
preview = null;
|
||||
selectedFile = null;
|
||||
importResult = null;
|
||||
importError = null;
|
||||
}
|
||||
|
||||
function resetExportState() {
|
||||
exportError = null;
|
||||
exportSuccess = false;
|
||||
}
|
||||
|
||||
// Import handlers
|
||||
async function handleFileSelect(file: File) {
|
||||
selectedFile = file;
|
||||
importError = null;
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
preview = await importApi.preview(file);
|
||||
importStep = 'preview';
|
||||
} catch (e) {
|
||||
importError = e instanceof Error ? e.message : 'Fehler beim Verarbeiten der Datei';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(duplicateAction: DuplicateAction, skipIndices: number[]) {
|
||||
if (!preview) return;
|
||||
|
||||
isImporting = true;
|
||||
importError = null;
|
||||
|
||||
try {
|
||||
importResult = await importApi.execute(preview.contacts, duplicateAction, skipIndices);
|
||||
importStep = 'result';
|
||||
await contactsStore.loadContacts();
|
||||
} catch (e) {
|
||||
importError = e instanceof Error ? e.message : 'Fehler beim Importieren';
|
||||
} finally {
|
||||
isImporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancelImport() {
|
||||
resetImportState();
|
||||
}
|
||||
|
||||
function handleImportDone() {
|
||||
goto('/');
|
||||
}
|
||||
|
||||
function handleImportMore() {
|
||||
resetImportState();
|
||||
}
|
||||
|
||||
async function handleDownloadTemplate() {
|
||||
try {
|
||||
await importApi.downloadTemplate();
|
||||
} catch (e) {
|
||||
importError = e instanceof Error ? e.message : 'Fehler beim Herunterladen';
|
||||
}
|
||||
}
|
||||
|
||||
// Export handlers
|
||||
async function handleExport() {
|
||||
isExporting = true;
|
||||
exportError = null;
|
||||
exportSuccess = false;
|
||||
|
||||
try {
|
||||
await exportApi.exportContacts({
|
||||
format: exportFormat,
|
||||
groupId: selectedGroupId || undefined,
|
||||
includeFavorites: onlyFavorites || undefined,
|
||||
includeArchived,
|
||||
});
|
||||
exportSuccess = true;
|
||||
} catch (e) {
|
||||
exportError = e instanceof Error ? e.message : 'Export fehlgeschlagen';
|
||||
} finally {
|
||||
isExporting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Daten - Kontakte</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">Daten verwalten</h1>
|
||||
<p class="text-muted-foreground mt-1">Kontakte importieren, exportieren und sichern</p>
|
||||
</div>
|
||||
<a href="/" class="btn btn-secondary">Zurück</a>
|
||||
</div>
|
||||
|
||||
<!-- Main Tabs -->
|
||||
<div class="flex gap-1 p-1 bg-muted rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setActiveTab('import')}
|
||||
class="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-medium transition-all
|
||||
{activeTab === 'import'
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setActiveTab('export')}
|
||||
class="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-medium transition-all
|
||||
{activeTab === 'export'
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ==================== IMPORT TAB ==================== -->
|
||||
{#if activeTab === 'import'}
|
||||
<!-- Import Source Tabs -->
|
||||
<div class="flex gap-2 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setImportSource('file')}
|
||||
class="px-4 py-2 font-medium transition-colors border-b-2 -mb-px
|
||||
{importSource === 'file'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Datei
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setImportSource('google')}
|
||||
class="px-4 py-2 font-medium transition-colors border-b-2 -mb-px
|
||||
{importSource === 'google'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Google
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Import Error -->
|
||||
{#if importError && importSource === 'file'}
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
|
||||
{importError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- File Import -->
|
||||
{#if importSource === 'file'}
|
||||
{#if importStep === 'upload'}
|
||||
<div class="space-y-6">
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
<p class="mt-4 text-muted-foreground">Datei wird verarbeitet...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<FileUploader onFileSelect={handleFileSelect} />
|
||||
|
||||
<div class="bg-card rounded-xl p-6 space-y-4">
|
||||
<h3 class="font-semibold text-foreground">Unterstützte Formate</h3>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center text-primary flex-shrink-0"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-foreground">vCard (.vcf)</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Standard-Format für Kontakte, kompatibel mit allen gängigen Apps
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center text-green-500 flex-shrink-0"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-foreground">CSV (.csv)</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Tabellen-Format, ideal für Excel oder Google Sheets
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDownloadTemplate}
|
||||
class="text-primary hover:underline text-sm inline-flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
CSV-Vorlage herunterladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if importStep === 'preview' && preview}
|
||||
<ImportPreview
|
||||
{preview}
|
||||
onImport={handleImport}
|
||||
onCancel={handleCancelImport}
|
||||
{isImporting}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if importStep === 'result' && importResult}
|
||||
<div class="bg-card rounded-xl p-8 text-center space-y-6">
|
||||
<div
|
||||
class="w-20 h-20 mx-auto rounded-full bg-green-500/10 flex items-center justify-center text-green-500"
|
||||
>
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">Import abgeschlossen</h2>
|
||||
<p class="text-muted-foreground mt-2">Deine Kontakte wurden erfolgreich importiert</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 max-w-md mx-auto">
|
||||
<div class="bg-green-500/10 rounded-lg p-4">
|
||||
<div class="text-3xl font-bold text-green-500">{importResult.imported}</div>
|
||||
<div class="text-sm text-muted-foreground">Importiert</div>
|
||||
</div>
|
||||
<div class="bg-blue-500/10 rounded-lg p-4">
|
||||
<div class="text-3xl font-bold text-blue-500">{importResult.merged}</div>
|
||||
<div class="text-sm text-muted-foreground">Zusammengeführt</div>
|
||||
</div>
|
||||
<div class="bg-gray-500/10 rounded-lg p-4">
|
||||
<div class="text-3xl font-bold text-gray-500">{importResult.skipped}</div>
|
||||
<div class="text-sm text-muted-foreground">Übersprungen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if importResult.errors.length > 0}
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-left">
|
||||
<h3 class="font-medium text-red-500 mb-2">Fehler</h3>
|
||||
<ul class="text-sm text-red-400 space-y-1">
|
||||
{#each importResult.errors as err}
|
||||
<li>{err.contactName}: {err.error}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-center gap-3">
|
||||
<button type="button" onclick={handleImportMore} class="btn btn-secondary">
|
||||
Weitere importieren
|
||||
</button>
|
||||
<button type="button" onclick={handleImportDone} class="btn btn-primary">
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Google Import -->
|
||||
{#if importSource === 'google'}
|
||||
<GoogleImport />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ==================== EXPORT TAB ==================== -->
|
||||
{#if activeTab === 'export'}
|
||||
<div class="space-y-6">
|
||||
<!-- Success Message -->
|
||||
{#if exportSuccess}
|
||||
<div
|
||||
class="bg-green-500/10 border border-green-500/20 rounded-lg p-4 text-green-600 dark:text-green-400 flex items-center gap-3"
|
||||
>
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Export erfolgreich! Die Datei wurde heruntergeladen.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if exportError}
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
|
||||
{exportError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Format Selection -->
|
||||
<div class="bg-card rounded-xl p-6 space-y-4">
|
||||
<h3 class="font-semibold text-foreground">Format wählen</h3>
|
||||
<div class="grid sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (exportFormat = 'vcard')}
|
||||
class="p-4 rounded-lg border-2 transition-colors text-left
|
||||
{exportFormat === 'vcard'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-muted-foreground'}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center text-primary"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-foreground">vCard</div>
|
||||
<div class="text-sm text-muted-foreground">.vcf - Universelles Kontaktformat</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (exportFormat = 'csv')}
|
||||
class="p-4 rounded-lg border-2 transition-colors text-left
|
||||
{exportFormat === 'csv'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-muted-foreground'}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-12 h-12 rounded-lg bg-green-500/10 flex items-center justify-center text-green-500"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-foreground">CSV</div>
|
||||
<div class="text-sm text-muted-foreground">.csv - Für Excel & Tabellen</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Options -->
|
||||
<div class="bg-card rounded-xl p-6 space-y-4">
|
||||
<h3 class="font-semibold text-foreground">Filter</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Group Filter -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Gruppe</label>
|
||||
<select
|
||||
bind:value={selectedGroupId}
|
||||
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
>
|
||||
<option value={null}>Alle Kontakte</option>
|
||||
{#each groupsStore.groups as group}
|
||||
<option value={group.id}>{group.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Favorites Only -->
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={onlyFavorites}
|
||||
class="w-5 h-5 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="text-foreground">Nur Favoriten exportieren</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Options -->
|
||||
<div class="bg-card rounded-xl p-6 space-y-4">
|
||||
<h3 class="font-semibold text-foreground">Optionen</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={includeNotes}
|
||||
class="w-5 h-5 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-foreground">Notizen einschließen</span>
|
||||
<p class="text-sm text-muted-foreground">Notizen zu Kontakten mit exportieren</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={includePhotos}
|
||||
class="w-5 h-5 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-foreground">Fotos einschließen</span>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Kontaktfotos mit exportieren (größere Datei)
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={includeArchived}
|
||||
class="w-5 h-5 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-foreground">Archivierte einschließen</span>
|
||||
<p class="text-sm text-muted-foreground">Auch archivierte Kontakte exportieren</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Button -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleExport}
|
||||
disabled={isExporting}
|
||||
class="btn btn-primary px-8"
|
||||
>
|
||||
{#if isExporting}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
||||
></span>
|
||||
Exportiere...
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Kontakte exportieren
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,16 +1,23 @@
|
|||
<script lang="ts">
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,283 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import FileUploader from '$lib/components/import/FileUploader.svelte';
|
||||
import ImportPreview from '$lib/components/import/ImportPreview.svelte';
|
||||
import GoogleImport from '$lib/components/import/GoogleImport.svelte';
|
||||
import { importApi, type ImportPreviewResponse, type DuplicateAction } from '$lib/api/import';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import '$lib/i18n';
|
||||
|
||||
type Tab = 'file' | 'google';
|
||||
type Step = 'upload' | 'preview' | 'result';
|
||||
|
||||
// Get initial tab from URL
|
||||
let activeTab = $state<Tab>(($page.url.searchParams.get('tab') as Tab) || 'file');
|
||||
|
||||
let step = $state<Step>('upload');
|
||||
let isLoading = $state(false);
|
||||
let isImporting = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let preview = $state<ImportPreviewResponse | null>(null);
|
||||
let result = $state<{
|
||||
imported: number;
|
||||
skipped: number;
|
||||
merged: number;
|
||||
errors: { index: number; contactName: string; error: string }[];
|
||||
} | null>(null);
|
||||
|
||||
function setActiveTab(tab: Tab) {
|
||||
activeTab = tab;
|
||||
// Reset file import state when switching tabs
|
||||
step = 'upload';
|
||||
preview = null;
|
||||
selectedFile = null;
|
||||
result = null;
|
||||
error = null;
|
||||
// Update URL without navigation
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('tab', tab);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
|
||||
async function handleFileSelect(file: File) {
|
||||
selectedFile = file;
|
||||
error = null;
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
preview = await importApi.preview(file);
|
||||
step = 'preview';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Verarbeiten der Datei';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(duplicateAction: DuplicateAction, skipIndices: number[]) {
|
||||
if (!preview) return;
|
||||
|
||||
isImporting = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
result = await importApi.execute(preview.contacts, duplicateAction, skipIndices);
|
||||
step = 'result';
|
||||
// Refresh contacts list
|
||||
await contactsStore.loadContacts();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Importieren';
|
||||
} finally {
|
||||
isImporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
step = 'upload';
|
||||
preview = null;
|
||||
selectedFile = null;
|
||||
error = null;
|
||||
}
|
||||
|
||||
function handleDone() {
|
||||
goto('/');
|
||||
}
|
||||
|
||||
function handleImportMore() {
|
||||
step = 'upload';
|
||||
preview = null;
|
||||
selectedFile = null;
|
||||
result = null;
|
||||
error = null;
|
||||
}
|
||||
|
||||
async function handleDownloadTemplate() {
|
||||
try {
|
||||
await importApi.downloadTemplate();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Herunterladen';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('import.title')} - Contacts</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('import.title')}</h1>
|
||||
<p class="text-muted-foreground mt-1">{$_('import.subtitle')}</p>
|
||||
</div>
|
||||
<a href="/" class="btn btn-secondary">
|
||||
{$_('common.back')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setActiveTab('file')}
|
||||
class="px-4 py-2 font-medium transition-colors border-b-2 -mb-px
|
||||
{activeTab === 'file'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
{$_('import.tabs.file')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setActiveTab('google')}
|
||||
class="px-4 py-2 font-medium transition-colors border-b-2 -mb-px
|
||||
{activeTab === 'google'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
{$_('import.tabs.google')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error message (only for file import) -->
|
||||
{#if error && activeTab === 'file'}
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- File Import Tab -->
|
||||
{#if activeTab === 'file'}
|
||||
<!-- Step: Upload -->
|
||||
{#if step === 'upload'}
|
||||
<div class="space-y-6">
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<div
|
||||
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
|
||||
></div>
|
||||
<p class="mt-4 text-muted-foreground">{$_('import.processing')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<FileUploader onFileSelect={handleFileSelect} />
|
||||
|
||||
<div class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDownloadTemplate}
|
||||
class="text-primary hover:underline text-sm inline-flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
{$_('import.downloadTemplate')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step: Preview -->
|
||||
{#if step === 'preview' && preview}
|
||||
<ImportPreview {preview} onImport={handleImport} onCancel={handleCancel} {isImporting} />
|
||||
{/if}
|
||||
|
||||
<!-- Step: Result -->
|
||||
{#if step === 'result' && result}
|
||||
<div class="bg-card rounded-xl p-8 text-center space-y-6">
|
||||
<div
|
||||
class="w-20 h-20 mx-auto rounded-full bg-green-500/10 flex items-center justify-center text-green-500"
|
||||
>
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">{$_('import.result.title')}</h2>
|
||||
<p class="text-muted-foreground mt-2">{$_('import.result.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 max-w-md mx-auto">
|
||||
<div class="bg-green-500/10 rounded-lg p-4">
|
||||
<div class="text-3xl font-bold text-green-500">{result.imported}</div>
|
||||
<div class="text-sm text-muted-foreground">{$_('import.result.imported')}</div>
|
||||
</div>
|
||||
<div class="bg-blue-500/10 rounded-lg p-4">
|
||||
<div class="text-3xl font-bold text-blue-500">{result.merged}</div>
|
||||
<div class="text-sm text-muted-foreground">{$_('import.result.merged')}</div>
|
||||
</div>
|
||||
<div class="bg-gray-500/10 rounded-lg p-4">
|
||||
<div class="text-3xl font-bold text-gray-500">{result.skipped}</div>
|
||||
<div class="text-sm text-muted-foreground">{$_('import.result.skipped')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if result.errors.length > 0}
|
||||
<div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-left">
|
||||
<h3 class="font-medium text-red-500 mb-2">{$_('import.result.errors')}</h3>
|
||||
<ul class="text-sm text-red-400 space-y-1">
|
||||
{#each result.errors as err}
|
||||
<li>{err.contactName}: {err.error}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-center gap-3">
|
||||
<button type="button" onclick={handleImportMore} class="btn btn-secondary">
|
||||
{$_('import.result.importMore')}
|
||||
</button>
|
||||
<button type="button" onclick={handleDone} class="btn btn-primary">
|
||||
{$_('import.result.done')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Google Import Tab -->
|
||||
{#if activeTab === 'google'}
|
||||
<GoogleImport />
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -53,12 +53,6 @@
|
|||
{ value: 'yyyy-MM-dd', label: 'JJJJ-MM-TT (ISO)' },
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
|
|
|
|||
847
apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte
Normal file
847
apps/contacts/apps/web/src/routes/(app)/tags/+page.svelte
Normal file
|
|
@ -0,0 +1,847 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { tagsApi } from '$lib/api/contacts';
|
||||
import type { ContactTag } from '$lib/api/contacts';
|
||||
|
||||
let loading = $state(true);
|
||||
let tags = $state<ContactTag[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let searchQuery = $state('');
|
||||
|
||||
// Modal state
|
||||
let showModal = $state(false);
|
||||
let editingTag = $state<ContactTag | null>(null);
|
||||
let tagName = $state('');
|
||||
let tagColor = $state('#6366f1');
|
||||
let saving = $state(false);
|
||||
|
||||
const filteredTags = $derived.by(() => {
|
||||
if (!searchQuery.trim()) return tags;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return tags.filter((t) => t.name.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
const colorOptions = [
|
||||
'#ef4444', // red
|
||||
'#f97316', // orange
|
||||
'#f59e0b', // amber
|
||||
'#84cc16', // lime
|
||||
'#22c55e', // green
|
||||
'#14b8a6', // teal
|
||||
'#06b6d4', // cyan
|
||||
'#3b82f6', // blue
|
||||
'#6366f1', // indigo
|
||||
'#8b5cf6', // violet
|
||||
'#a855f7', // purple
|
||||
'#ec4899', // pink
|
||||
'#64748b', // slate
|
||||
];
|
||||
|
||||
async function loadTags() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const response = await tagsApi.list();
|
||||
tags = response.tags || [];
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $_('messages.error');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingTag = null;
|
||||
tagName = '';
|
||||
tagColor = '#6366f1';
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEditModal(tag: ContactTag) {
|
||||
editingTag = tag;
|
||||
tagName = tag.name;
|
||||
tagColor = tag.color || '#6366f1';
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal = false;
|
||||
editingTag = null;
|
||||
tagName = '';
|
||||
tagColor = '#6366f1';
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!tagName.trim()) return;
|
||||
|
||||
saving = true;
|
||||
error = null;
|
||||
try {
|
||||
if (editingTag) {
|
||||
const response = await tagsApi.update(editingTag.id, {
|
||||
name: tagName.trim(),
|
||||
color: tagColor,
|
||||
});
|
||||
tags = tags.map((t) => (t.id === editingTag!.id ? response.tag : t));
|
||||
} else {
|
||||
const response = await tagsApi.create({
|
||||
name: tagName.trim(),
|
||||
color: tagColor,
|
||||
});
|
||||
tags = [...tags, response.tag];
|
||||
}
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $_('messages.error');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(tag: ContactTag) {
|
||||
if (!confirm($_('tags.confirmDelete', { values: { name: tag.name } }))) return;
|
||||
|
||||
try {
|
||||
await tagsApi.delete(tag.id);
|
||||
tags = tags.filter((t) => t.id !== tag.id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : $_('messages.error');
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadTags);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('tags.title')} - Contacts</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<a href="/" class="back-button" aria-label={$_('common.back')}>
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="title">{$_('tags.title')}</h1>
|
||||
<button onclick={openCreateModal} class="add-button" aria-label={$_('tags.new')}>
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search-wrapper">
|
||||
<svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$_('tags.search')}
|
||||
bind:value={searchQuery}
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner" role="alert">
|
||||
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-container">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{:else if tags.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="empty-title">{$_('tags.noTags')}</h2>
|
||||
<p class="empty-description">{$_('tags.createFirst')}</p>
|
||||
<button onclick={openCreateModal} class="btn btn-primary">
|
||||
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
{$_('tags.new')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if filteredTags.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="empty-title">{$_('tags.noResults')}</h2>
|
||||
<p class="empty-description">{$_('tags.noResultsFor', { values: { query: searchQuery } })}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="tags-grid">
|
||||
{#each filteredTags as tag (tag.id)}
|
||||
<div class="tag-card">
|
||||
<div class="tag-color" style="background-color: {tag.color || '#6366f1'}">
|
||||
<svg class="tag-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="tag-info">
|
||||
<h3 class="tag-name">{tag.name}</h3>
|
||||
</div>
|
||||
<div class="tag-actions">
|
||||
<button
|
||||
onclick={() => openEditModal(tag)}
|
||||
class="action-button"
|
||||
aria-label={$_('actions.edit')}
|
||||
>
|
||||
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleDelete(tag)}
|
||||
class="action-button delete"
|
||||
aria-label={$_('actions.delete')}
|
||||
>
|
||||
<svg class="icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="tags-count">
|
||||
{tags.length}
|
||||
{tags.length === 1 ? $_('tags.tagSingular') : $_('tags.tagPlural')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
{#if showModal}
|
||||
<div class="modal-backdrop" onclick={closeModal} role="presentation">
|
||||
<div
|
||||
class="modal"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
<header class="modal-header">
|
||||
<h2 id="modal-title" class="modal-title">
|
||||
{editingTag ? $_('tags.edit') : $_('tags.new')}
|
||||
</h2>
|
||||
<button onclick={closeModal} class="modal-close" aria-label={$_('common.cancel')}>
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="tag-name" class="form-label">{$_('tags.name')}</label>
|
||||
<input
|
||||
id="tag-name"
|
||||
type="text"
|
||||
bind:value={tagName}
|
||||
placeholder={$_('tags.namePlaceholder')}
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{$_('tags.color')}</label>
|
||||
<div class="color-picker">
|
||||
{#each colorOptions as color}
|
||||
<button
|
||||
type="button"
|
||||
class="color-option"
|
||||
class:selected={tagColor === color}
|
||||
style="background-color: {color}"
|
||||
onclick={() => (tagColor = color)}
|
||||
aria-label={color}
|
||||
>
|
||||
{#if tagColor === color}
|
||||
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="3"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">{$_('tags.preview')}</label>
|
||||
<div class="tag-preview">
|
||||
<span class="preview-tag" style="background-color: {tagColor}">
|
||||
{tagName || $_('tags.namePlaceholder')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="modal-footer">
|
||||
<button onclick={closeModal} class="btn btn-secondary" disabled={saving}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button onclick={handleSave} class="btn btn-primary" disabled={saving || !tagName.trim()}>
|
||||
{#if saving}
|
||||
<span class="btn-spinner"></span>
|
||||
{/if}
|
||||
{editingTag ? $_('actions.save') : $_('actions.create')}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 2rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 0;
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
background: hsl(var(--background));
|
||||
z-index: 10;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
top: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.2);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem 0.875rem 3rem;
|
||||
border: 1.5px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.9375rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
border: 1px solid hsl(0 84% 60% / 0.3);
|
||||
border-radius: 0.75rem;
|
||||
color: hsl(0 84% 60%);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 3px solid hsl(var(--muted));
|
||||
border-top-color: hsl(var(--primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-icon svg {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 1.5rem;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* Tags Grid */
|
||||
.tags-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tag-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tag-card:hover {
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.05);
|
||||
}
|
||||
|
||||
.tag-color {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.625rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tag-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tag-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tag-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.tag-card:hover .tag-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.action-button.delete:hover {
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
color: hsl(0 84% 60%);
|
||||
}
|
||||
|
||||
/* Count */
|
||||
.tags-count {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: hsl(var(--background));
|
||||
border-radius: 1rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1.5px solid hsl(var(--border));
|
||||
border-radius: 0.625rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.9375rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
/* Color Picker */
|
||||
.color-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-option {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.color-option:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.color-option.selected {
|
||||
border-color: hsl(var(--foreground));
|
||||
box-shadow: 0 0 0 2px hsl(var(--background));
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Tag Preview */
|
||||
.tag-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.625rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: hsl(var(--muted-foreground) / 0.2);
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
/* Icons */
|
||||
.icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.icon-sm {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue