feat: Kontakte mit Outlook-Feldern + CardDAV-Server + Sharing
Komplette Kontakte-Ueberarbeitung analog zum Kalender-Ausbau.
Backend-Model:
* AddressBook: color (pro Buch), ausserdem Per-User-Color via
AddressBookShare.color wie bei CalendarShare.
* Contact: volle Outlook-artige Struktur - prefix/first/middle/
last/suffix, display_name, nickname, organization, department,
job_title, birthday, anniversary, notes, photo sowie JSON-
Spalten fuer mehrfach vorhandene Felder (emails, phones,
addresses mit allen Adressteilen, websites, impp, categories).
Backend-API:
* REST CRUD uebernimmt die neuen Felder und generiert vCard 3.0
als Source of Truth fuer CardDAV. Voller vCard-Parser +
-Builder mit Escape/Unescape, TYPE-Parametern, Line-Folding.
* Neuer Endpoint PUT /addressbooks/<id>/my-color - persoenliche
Farbe pro Buch ohne den Besitzer zu beeinflussen.
* SSE-Events vom Typ 'addressbook' an Besitzer + alle Share-
Empfaenger bei jeder Aenderung.
CardDAV-Server (backend/app/dav/carddav.py):
* Volle Discovery via principal - addressbook-home-set wird
neben calendar-home-set annonciert.
* PROPFIND/REPORT/GET/PUT/DELETE/MKCOL fuer
/dav/<user>/ab-<id>/ und /<...>/{uid}.vcf
* addressbook-query + addressbook-multiget REPORTs
* ETag-basierte Konfliktpruefung via If-Match/If-None-Match
Frontend (ContactsView.vue):
* Komplett neuer Editor mit vier Tabs: Allgemein (Name, Org),
Kommunikation (Emails/Phones/Websites/IMPP dynamisch),
Adressen (mehrere mit allen Teilen), Details (Geburtstag,
Jahrestag, Kategorien, Notizen).
* Avatar mit Fotoauswahl oder Initialen-Farbkreis.
* Kalender-Sharing-Flow 1:1 uebernommen: Autocomplete fuer
Benutzersuche, Share-Liste mit Stift zum Bearbeiten, Muelleimer
zum Entfernen, Per-User-Farbe, CardDAV-URL-Info-Block pro
Adressbuch, Live-Refresh via SSE.
* Suche durchsucht Displayname, E-Mail und Firma.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,25 +10,43 @@
|
||||
|
||||
<div class="contacts-layout">
|
||||
<aside class="books-sidebar">
|
||||
<h4>Adressbücher</h4>
|
||||
<div v-for="book in addressBooks" :key="book.id"
|
||||
class="book-item" :class="{ active: selectedBookId === book.id }"
|
||||
@click="selectBook(book.id)">
|
||||
<i class="pi pi-book"></i>
|
||||
<span>{{ book.name }}</span>
|
||||
<span class="book-color" :style="{ background: book.color }"></span>
|
||||
<span class="book-name">{{ book.name }}</span>
|
||||
<span v-if="book.permission !== 'owner'" class="shared-label">(geteilt)</span>
|
||||
<span class="count">{{ book.contact_count }}</span>
|
||||
<Button icon="pi pi-ellipsis-v" text size="small" class="book-menu"
|
||||
@click.stop="openBookMenu(book)" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="contacts-main">
|
||||
<div class="search-bar">
|
||||
<InputText v-model="searchQuery" placeholder="Kontakte suchen..." fluid @input="loadContacts" />
|
||||
<InputText v-model="searchQuery" placeholder="Kontakte suchen..." fluid @input="onSearch" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="contacts" :loading="loading" striped-rows @row-click="openEditContact">
|
||||
<DataTable :value="contacts" :loading="loading" striped-rows
|
||||
@row-click="onRowClick" :rowClass="() => 'clickable'">
|
||||
<template #empty><p class="empty">Keine Kontakte</p></template>
|
||||
<Column field="display_name" header="Name" sortable />
|
||||
<Column field="email" header="E-Mail" sortable />
|
||||
<Column field="phone" header="Telefon" />
|
||||
<Column header="Name" sortable sortField="display_name">
|
||||
<template #body="{ data }">
|
||||
<div class="contact-row">
|
||||
<div class="avatar" :style="{ background: avatarColor(data) }">
|
||||
<img v-if="data.photo" :src="data.photo" />
|
||||
<span v-else>{{ initials(data) }}</span>
|
||||
</div>
|
||||
<div class="contact-name">
|
||||
<strong>{{ data.display_name || '—' }}</strong>
|
||||
<small v-if="data.organization">{{ data.organization }}{{ data.job_title ? ' · ' + data.job_title : '' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="primary_email" header="E-Mail" sortable />
|
||||
<Column field="primary_phone" header="Telefon" />
|
||||
<Column header="" style="width: 80px">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click.stop="confirmDeleteContact(data)" />
|
||||
@@ -44,182 +62,700 @@
|
||||
<label>Name</label>
|
||||
<InputText v-model="newBookName" fluid autofocus @keyup.enter="createBook" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Farbe</label>
|
||||
<InputText v-model="newBookColor" type="color" style="width: 60px; height: 36px" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showNewBook = false" />
|
||||
<Button label="Erstellen" @click="createBook" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Contact Form -->
|
||||
<Dialog v-model:visible="showContactForm" :header="editingContact ? 'Kontakt bearbeiten' : 'Neuer Kontakt'" modal :style="{ width: '500px' }">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model="contactForm.display_name" fluid autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>E-Mail</label>
|
||||
<InputText v-model="contactForm.email" type="email" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Telefon</label>
|
||||
<InputText v-model="contactForm.phone" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Organisation</label>
|
||||
<InputText v-model="contactForm.organization" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notizen</label>
|
||||
<Textarea v-model="contactForm.notes" rows="3" fluid />
|
||||
<!-- Book Menu (3-dot) -->
|
||||
<Dialog v-model:visible="showBookMenu" header="Adressbuch-Optionen" modal :style="{ width: '560px' }">
|
||||
<div v-if="menuBook" class="book-menu-content">
|
||||
<p><strong>{{ menuBook.name }}</strong></p>
|
||||
|
||||
<div class="field">
|
||||
<label>
|
||||
{{ menuBook.permission === 'owner' ? 'Farbe' : 'Persönliche Anzeigefarbe' }}
|
||||
</label>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<InputText :modelValue="menuBook.color" @change="onBookColorChange($event)"
|
||||
type="color" style="width: 60px; height: 36px" />
|
||||
<span v-if="menuBook.permission !== 'owner'" class="hint">
|
||||
Nur für deine Ansicht — {{ menuBook.owner_name }} behält seine Farbe
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="menuBook.permission === 'owner'" class="field">
|
||||
<label>Mit Benutzer teilen</label>
|
||||
<div class="share-row">
|
||||
<div style="position: relative; flex: 1;">
|
||||
<InputText v-model="shareUsername" placeholder="Benutzername suchen..."
|
||||
fluid @input="onShareSearch" />
|
||||
<div v-if="shareSearchResults.length" class="user-search-popup">
|
||||
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
|
||||
@click="shareUsername = u.username; shareSearchResults = []">
|
||||
<i class="pi pi-user"></i> {{ u.username }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Select v-model="sharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
|
||||
<Button label="Teilen" size="small" @click="shareBook" />
|
||||
</div>
|
||||
<div v-if="bookShares.length" class="existing-shares">
|
||||
<template v-for="s in bookShares" :key="s.id">
|
||||
<div v-if="editingShareId !== s.id" class="share-perm-item">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>{{ s.username }}</span>
|
||||
<span class="perm-label">{{ s.permission === 'readwrite' ? 'Lesen+Schreiben' : 'Lesen' }}</span>
|
||||
<Button icon="pi pi-pencil" text size="small" @click="startEditShare(s)" />
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeShare(s.id)" />
|
||||
</div>
|
||||
<div v-else class="share-perm-item editing">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>{{ s.username }}</span>
|
||||
<Select v-model="editSharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
|
||||
<Button icon="pi pi-check" text size="small" severity="success" @click="saveEditShare(s)" />
|
||||
<Button icon="pi pi-times" text size="small" @click="editingShareId = null" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field carddav-block">
|
||||
<label><i class="pi pi-info-circle"></i> CardDAV-Zugang</label>
|
||||
<div class="url-row">
|
||||
<strong>Auto-Discovery</strong>
|
||||
<code>{{ origin }}/dav/</code>
|
||||
<Button icon="pi pi-copy" text size="small" @click="copyText(origin + '/dav/')" />
|
||||
</div>
|
||||
<div class="url-row">
|
||||
<strong>Dieses Adressbuch</strong>
|
||||
<code>{{ origin }}/dav/{{ username }}/ab-{{ menuBook.id }}/</code>
|
||||
<Button icon="pi pi-copy" text size="small"
|
||||
@click="copyText(`${origin}/dav/${username}/ab-${menuBook.id}/`)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button v-if="menuBook.permission === 'owner'" label="Adressbuch löschen"
|
||||
severity="danger" text size="small" @click="confirmDeleteBook = true" />
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- Contact Editor -->
|
||||
<Dialog v-model:visible="showContactDialog" :header="editingContactId ? 'Kontakt bearbeiten' : 'Neuer Kontakt'"
|
||||
modal :style="{ width: '720px' }" maximizable>
|
||||
<Tabs v-model:value="activeTab">
|
||||
<TabList>
|
||||
<Tab value="general">Allgemein</Tab>
|
||||
<Tab value="communication">Kommunikation</Tab>
|
||||
<Tab value="address">Adressen</Tab>
|
||||
<Tab value="details">Details</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel value="general">
|
||||
<div class="photo-row">
|
||||
<div class="avatar large" :style="{ background: avatarColor(contactForm) }">
|
||||
<img v-if="contactForm.photo" :src="contactForm.photo" />
|
||||
<span v-else>{{ initials(contactForm) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Button icon="pi pi-upload" label="Foto hochladen" size="small" @click="triggerPhotoUpload" />
|
||||
<Button v-if="contactForm.photo" icon="pi pi-times" label="Entfernen" size="small" text
|
||||
@click="contactForm.photo = null" />
|
||||
<input ref="photoInput" type="file" accept="image/*" hidden @change="onPhotoSelected" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field" style="max-width:120px">
|
||||
<label>Anrede</label>
|
||||
<InputText v-model="contactForm.prefix" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Vorname</label>
|
||||
<InputText v-model="contactForm.first_name" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Mittelname</label>
|
||||
<InputText v-model="contactForm.middle_name" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Nachname</label>
|
||||
<InputText v-model="contactForm.last_name" fluid />
|
||||
</div>
|
||||
<div class="field" style="max-width:120px">
|
||||
<label>Suffix</label>
|
||||
<InputText v-model="contactForm.suffix" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Spitzname</label>
|
||||
<InputText v-model="contactForm.nickname" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Anzeigename (optional – wird sonst aus Namen zusammengesetzt)</label>
|
||||
<InputText v-model="contactForm.display_name" fluid />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Firma</label>
|
||||
<InputText v-model="contactForm.organization" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Abteilung</label>
|
||||
<InputText v-model="contactForm.department" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Position</label>
|
||||
<InputText v-model="contactForm.job_title" fluid />
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value="communication">
|
||||
<h5>E-Mail-Adressen</h5>
|
||||
<div v-for="(e, i) in contactForm.emails" :key="'e'+i" class="multi-row">
|
||||
<Select v-model="e.type" :options="emailTypes" optionLabel="label" optionValue="value" style="width:120px" />
|
||||
<InputText v-model="e.value" placeholder="name@example.com" fluid />
|
||||
<Button icon="pi pi-trash" text severity="danger" size="small" @click="contactForm.emails.splice(i,1)" />
|
||||
</div>
|
||||
<Button icon="pi pi-plus" label="E-Mail hinzufügen" size="small" text
|
||||
@click="contactForm.emails.push({ type: 'home', value: '' })" />
|
||||
|
||||
<h5 style="margin-top:1rem">Telefonnummern</h5>
|
||||
<div v-for="(p, i) in contactForm.phones" :key="'p'+i" class="multi-row">
|
||||
<Select v-model="p.type" :options="phoneTypes" optionLabel="label" optionValue="value" style="width:120px" />
|
||||
<InputText v-model="p.value" placeholder="+49..." fluid />
|
||||
<Button icon="pi pi-trash" text severity="danger" size="small" @click="contactForm.phones.splice(i,1)" />
|
||||
</div>
|
||||
<Button icon="pi pi-plus" label="Nummer hinzufügen" size="small" text
|
||||
@click="contactForm.phones.push({ type: 'cell', value: '' })" />
|
||||
|
||||
<h5 style="margin-top:1rem">Websites</h5>
|
||||
<div v-for="(w, i) in contactForm.websites" :key="'w'+i" class="multi-row">
|
||||
<Select v-model="w.type" :options="urlTypes" optionLabel="label" optionValue="value" style="width:120px" />
|
||||
<InputText v-model="w.value" placeholder="https://..." fluid />
|
||||
<Button icon="pi pi-trash" text severity="danger" size="small" @click="contactForm.websites.splice(i,1)" />
|
||||
</div>
|
||||
<Button icon="pi pi-plus" label="Website hinzufügen" size="small" text
|
||||
@click="contactForm.websites.push({ type: 'home', value: '' })" />
|
||||
|
||||
<h5 style="margin-top:1rem">Messenger</h5>
|
||||
<div v-for="(m, i) in contactForm.impp" :key="'i'+i" class="multi-row">
|
||||
<InputText v-model="m.protocol" placeholder="xmpp, skype, signal..." style="width:150px" />
|
||||
<InputText v-model="m.value" placeholder="Benutzername" fluid />
|
||||
<Button icon="pi pi-trash" text severity="danger" size="small" @click="contactForm.impp.splice(i,1)" />
|
||||
</div>
|
||||
<Button icon="pi pi-plus" label="Messenger hinzufügen" size="small" text
|
||||
@click="contactForm.impp.push({ protocol: '', value: '' })" />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value="address">
|
||||
<div v-for="(a, i) in contactForm.addresses" :key="'a'+i" class="address-card">
|
||||
<div class="field-row">
|
||||
<div class="field" style="width:140px">
|
||||
<label>Typ</label>
|
||||
<Select v-model="a.type" :options="addressTypes" optionLabel="label" optionValue="value" />
|
||||
</div>
|
||||
<div style="flex:1"></div>
|
||||
<Button icon="pi pi-trash" text severity="danger" size="small" @click="contactForm.addresses.splice(i,1)" />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field"><label>Straße</label><InputText v-model="a.street" fluid /></div>
|
||||
<div class="field" style="max-width:120px"><label>PO-Box</label><InputText v-model="a.po_box" /></div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field" style="max-width:150px"><label>PLZ</label><InputText v-model="a.postal_code" /></div>
|
||||
<div class="field"><label>Ort</label><InputText v-model="a.city" fluid /></div>
|
||||
<div class="field"><label>Bundesland</label><InputText v-model="a.region" fluid /></div>
|
||||
</div>
|
||||
<div class="field"><label>Land</label><InputText v-model="a.country" fluid /></div>
|
||||
</div>
|
||||
<Button icon="pi pi-plus" label="Adresse hinzufügen" size="small" text
|
||||
@click="contactForm.addresses.push({ type: 'home', street: '', po_box: '', postal_code: '', city: '', region: '', country: '' })" />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value="details">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Geburtstag</label>
|
||||
<InputText v-model="contactForm.birthday" type="date" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Jahrestag</label>
|
||||
<InputText v-model="contactForm.anniversary" type="date" fluid />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Kategorien (kommagetrennt)</label>
|
||||
<InputText v-model="categoriesString" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Notizen</label>
|
||||
<Textarea v-model="contactForm.notes" rows="6" fluid />
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showContactForm = false" />
|
||||
<Button :label="editingContact ? 'Speichern' : 'Erstellen'" @click="saveContact" />
|
||||
<Button label="Abbrechen" text @click="showContactDialog = false" />
|
||||
<Button :label="editingContactId ? 'Speichern' : 'Erstellen'" @click="saveContact" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirm -->
|
||||
<Dialog v-model:visible="showDeleteConfirm" header="Kontakt loeschen" modal :style="{ width: '400px' }">
|
||||
<p>Moechtest du <strong>{{ deleteTarget?.display_name }}</strong> wirklich loeschen?</p>
|
||||
<Dialog v-model:visible="confirmDeleteContactDialog" header="Kontakt löschen" modal :style="{ width: '400px' }">
|
||||
<p>Möchtest du <strong>{{ deleteContactTarget?.display_name }}</strong> wirklich löschen?</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showDeleteConfirm = false" />
|
||||
<Button label="Loeschen" severity="danger" @click="doDeleteContact" />
|
||||
<Button label="Abbrechen" text @click="confirmDeleteContactDialog = false" />
|
||||
<Button label="Löschen" severity="danger" @click="deleteContact" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="confirmDeleteBook" header="Adressbuch löschen" modal :style="{ width: '400px' }">
|
||||
<p>Adressbuch <strong>{{ menuBook?.name }}</strong> mit allen Kontakten löschen?</p>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="confirmDeleteBook = false" />
|
||||
<Button label="Löschen" severity="danger" @click="deleteBook" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import apiClient from '../api/client'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Select from 'primevue/select'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import TabList from 'primevue/tablist'
|
||||
import Tab from 'primevue/tab'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
|
||||
const toast = useToast()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const addressBooks = ref([])
|
||||
const contacts = ref([])
|
||||
const selectedBookId = ref(null)
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
let searchTimer = null
|
||||
|
||||
const origin = computed(() => window.location.origin)
|
||||
const username = computed(() => auth.user?.username || '')
|
||||
|
||||
const showNewBook = ref(false)
|
||||
const newBookName = ref('')
|
||||
const newBookColor = ref('#3788d8')
|
||||
|
||||
const showContactForm = ref(false)
|
||||
const editingContact = ref(null)
|
||||
const contactForm = ref({ display_name: '', email: '', phone: '', organization: '', notes: '' })
|
||||
const showBookMenu = ref(false)
|
||||
const menuBook = ref(null)
|
||||
const bookShares = ref([])
|
||||
const shareUsername = ref('')
|
||||
const sharePermission = ref('read')
|
||||
const shareSearchResults = ref([])
|
||||
let shareSearchTimer = null
|
||||
const editingShareId = ref(null)
|
||||
const editSharePermission = ref('read')
|
||||
const confirmDeleteBook = ref(false)
|
||||
|
||||
const showContactDialog = ref(false)
|
||||
const editingContactId = ref(null)
|
||||
const activeTab = ref('general')
|
||||
const contactForm = reactive(emptyContact())
|
||||
const categoriesString = ref('')
|
||||
const photoInput = ref(null)
|
||||
const confirmDeleteContactDialog = ref(false)
|
||||
const deleteContactTarget = ref(null)
|
||||
|
||||
const permOptions = [
|
||||
{ label: 'Lesen', value: 'read' },
|
||||
{ label: 'Lesen+Schreiben', value: 'readwrite' },
|
||||
]
|
||||
const emailTypes = [
|
||||
{ label: 'Privat', value: 'home' }, { label: 'Geschäftlich', value: 'work' },
|
||||
{ label: 'Sonstige', value: 'other' },
|
||||
]
|
||||
const phoneTypes = [
|
||||
{ label: 'Mobil', value: 'cell' }, { label: 'Privat', value: 'home' },
|
||||
{ label: 'Geschäftlich', value: 'work' }, { label: 'Fax', value: 'fax' },
|
||||
{ label: 'Pager', value: 'pager' }, { label: 'Sonstige', value: 'other' },
|
||||
]
|
||||
const addressTypes = [
|
||||
{ label: 'Privat', value: 'home' }, { label: 'Geschäftlich', value: 'work' },
|
||||
{ label: 'Sonstige', value: 'other' },
|
||||
]
|
||||
const urlTypes = [
|
||||
{ label: 'Privat', value: 'home' }, { label: 'Geschäftlich', value: 'work' },
|
||||
{ label: 'Sonstige', value: 'other' },
|
||||
]
|
||||
|
||||
function emptyContact() {
|
||||
return {
|
||||
prefix: '', first_name: '', middle_name: '', last_name: '', suffix: '',
|
||||
display_name: '', nickname: '',
|
||||
organization: '', department: '', job_title: '',
|
||||
emails: [], phones: [], addresses: [], websites: [], impp: [], categories: [],
|
||||
birthday: '', anniversary: '', notes: '', photo: null,
|
||||
}
|
||||
}
|
||||
|
||||
function initials(c) {
|
||||
if (!c) return '?'
|
||||
const parts = []
|
||||
if (c.first_name) parts.push(c.first_name[0])
|
||||
if (c.last_name) parts.push(c.last_name[0])
|
||||
if (!parts.length && c.display_name) parts.push(c.display_name[0])
|
||||
return (parts.join('') || '?').toUpperCase()
|
||||
}
|
||||
|
||||
function avatarColor(c) {
|
||||
if (!c) return '#888'
|
||||
const s = (c.display_name || c.last_name || c.first_name || 'x').toLowerCase()
|
||||
let h = 0
|
||||
for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) >>> 0
|
||||
return `hsl(${h % 360}, 45%, 55%)`
|
||||
}
|
||||
|
||||
async function loadBooks() {
|
||||
const res = await apiClient.get('/addressbooks')
|
||||
addressBooks.value = res.data
|
||||
if (addressBooks.value.length && !selectedBookId.value) {
|
||||
selectedBookId.value = addressBooks.value[0].id
|
||||
await loadContacts()
|
||||
}
|
||||
if (!addressBooks.value.length) {
|
||||
await apiClient.post('/addressbooks', { name: 'Kontakte' })
|
||||
await loadBooks()
|
||||
await apiClient.post('/addressbooks', { name: 'Meine Kontakte', color: '#3788d8' })
|
||||
const res2 = await apiClient.get('/addressbooks')
|
||||
addressBooks.value = res2.data
|
||||
}
|
||||
if (!selectedBookId.value && addressBooks.value.length) {
|
||||
selectedBookId.value = addressBooks.value[0].id
|
||||
}
|
||||
}
|
||||
|
||||
async function selectBook(id) {
|
||||
selectedBookId.value = id
|
||||
await loadContacts()
|
||||
}
|
||||
|
||||
async function loadContacts() {
|
||||
if (!selectedBookId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params = searchQuery.value ? { search: searchQuery.value } : {}
|
||||
const res = await apiClient.get(`/addressbooks/${selectedBookId.value}/contacts`, { params })
|
||||
const res = await apiClient.get(`/addressbooks/${selectedBookId.value}/contacts`,
|
||||
{ params: { q: searchQuery.value || undefined } })
|
||||
contacts.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function selectBook(id) {
|
||||
selectedBookId.value = id
|
||||
loadContacts()
|
||||
function onSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(loadContacts, 250)
|
||||
}
|
||||
|
||||
async function createBook() {
|
||||
if (!newBookName.value.trim()) return
|
||||
await apiClient.post('/addressbooks', { name: newBookName.value.trim() })
|
||||
await apiClient.post('/addressbooks', { name: newBookName.value.trim(), color: newBookColor.value })
|
||||
showNewBook.value = false
|
||||
newBookName.value = ''
|
||||
await loadBooks()
|
||||
}
|
||||
|
||||
function openNewContact() {
|
||||
editingContact.value = null
|
||||
contactForm.value = { display_name: '', email: '', phone: '', organization: '', notes: '' }
|
||||
showContactForm.value = true
|
||||
function openBookMenu(book) {
|
||||
menuBook.value = book
|
||||
shareUsername.value = ''
|
||||
shareSearchResults.value = []
|
||||
editingShareId.value = null
|
||||
showBookMenu.value = true
|
||||
loadShares()
|
||||
}
|
||||
|
||||
function openEditContact(event) {
|
||||
const c = event.data
|
||||
editingContact.value = c
|
||||
contactForm.value = {
|
||||
display_name: c.display_name || '',
|
||||
email: c.email || '',
|
||||
phone: c.phone || '',
|
||||
organization: '',
|
||||
notes: '',
|
||||
async function loadShares() {
|
||||
if (!menuBook.value || menuBook.value.permission !== 'owner') {
|
||||
bookShares.value = []
|
||||
return
|
||||
}
|
||||
showContactForm.value = true
|
||||
try {
|
||||
const res = await apiClient.get(`/addressbooks/${menuBook.value.id}/shares`)
|
||||
bookShares.value = res.data
|
||||
} catch { bookShares.value = [] }
|
||||
}
|
||||
|
||||
function onShareSearch() {
|
||||
clearTimeout(shareSearchTimer)
|
||||
const q = shareUsername.value.trim()
|
||||
if (q.length < 2) { shareSearchResults.value = []; return }
|
||||
shareSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await apiClient.get('/users/search', { params: { q } })
|
||||
shareSearchResults.value = res.data
|
||||
} catch { shareSearchResults.value = [] }
|
||||
}, 250)
|
||||
}
|
||||
|
||||
async function shareBook() {
|
||||
if (!shareUsername.value.trim() || !menuBook.value) return
|
||||
try {
|
||||
await apiClient.post(`/addressbooks/${menuBook.value.id}/share`, {
|
||||
username: shareUsername.value.trim(), permission: sharePermission.value,
|
||||
})
|
||||
shareUsername.value = ''
|
||||
shareSearchResults.value = []
|
||||
await loadShares()
|
||||
toast.add({ severity: 'success', summary: 'Adressbuch geteilt', life: 2500 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function startEditShare(s) {
|
||||
editingShareId.value = s.id
|
||||
editSharePermission.value = s.permission
|
||||
}
|
||||
|
||||
async function saveEditShare(s) {
|
||||
if (!menuBook.value) return
|
||||
try {
|
||||
await apiClient.post(`/addressbooks/${menuBook.value.id}/share`, {
|
||||
username: s.username,
|
||||
permission: editSharePermission.value,
|
||||
})
|
||||
editingShareId.value = null
|
||||
await loadShares()
|
||||
toast.add({ severity: 'success', summary: 'Berechtigung aktualisiert', life: 2500 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function removeShare(shareId) {
|
||||
if (!menuBook.value) return
|
||||
await apiClient.delete(`/addressbooks/${menuBook.value.id}/shares/${shareId}`)
|
||||
await loadShares()
|
||||
}
|
||||
|
||||
async function onBookColorChange(ev) {
|
||||
if (!menuBook.value) return
|
||||
const color = ev.target.value
|
||||
try {
|
||||
const res = await apiClient.put(`/addressbooks/${menuBook.value.id}/my-color`, { color })
|
||||
menuBook.value.color = res.data.color
|
||||
await loadBooks()
|
||||
toast.add({ severity: 'success', summary: 'Farbe aktualisiert', life: 2000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBook() {
|
||||
if (!menuBook.value) return
|
||||
await apiClient.delete(`/addressbooks/${menuBook.value.id}`)
|
||||
showBookMenu.value = false
|
||||
confirmDeleteBook.value = false
|
||||
if (selectedBookId.value === menuBook.value.id) selectedBookId.value = null
|
||||
await loadBooks()
|
||||
}
|
||||
|
||||
function copyText(t) {
|
||||
navigator.clipboard.writeText(t)
|
||||
toast.add({ severity: 'info', summary: 'Kopiert', life: 1500 })
|
||||
}
|
||||
|
||||
function openNewContact() {
|
||||
editingContactId.value = null
|
||||
Object.assign(contactForm, emptyContact())
|
||||
categoriesString.value = ''
|
||||
activeTab.value = 'general'
|
||||
showContactDialog.value = true
|
||||
}
|
||||
|
||||
function onRowClick(ev) {
|
||||
openEditContact(ev.data)
|
||||
}
|
||||
|
||||
async function openEditContact(row) {
|
||||
editingContactId.value = row.id
|
||||
const res = await apiClient.get(`/contacts/${row.id}`)
|
||||
const c = res.data
|
||||
Object.assign(contactForm, emptyContact(), {
|
||||
prefix: c.prefix || '',
|
||||
first_name: c.first_name || '',
|
||||
middle_name: c.middle_name || '',
|
||||
last_name: c.last_name || '',
|
||||
suffix: c.suffix || '',
|
||||
display_name: c.display_name || '',
|
||||
nickname: c.nickname || '',
|
||||
organization: c.organization || '',
|
||||
department: c.department || '',
|
||||
job_title: c.job_title || '',
|
||||
emails: (c.emails || []).map(x => ({ ...x })),
|
||||
phones: (c.phones || []).map(x => ({ ...x })),
|
||||
addresses: (c.addresses || []).map(x => ({ ...x })),
|
||||
websites: (c.websites || []).map(x => ({ ...x })),
|
||||
impp: (c.impp || []).map(x => ({ ...x })),
|
||||
categories: c.categories || [],
|
||||
birthday: c.birthday || '',
|
||||
anniversary: c.anniversary || '',
|
||||
notes: c.notes || '',
|
||||
photo: c.photo || null,
|
||||
})
|
||||
categoriesString.value = (c.categories || []).join(', ')
|
||||
activeTab.value = 'general'
|
||||
showContactDialog.value = true
|
||||
}
|
||||
|
||||
function triggerPhotoUpload() {
|
||||
photoInput.value?.click()
|
||||
}
|
||||
|
||||
function onPhotoSelected(ev) {
|
||||
const file = ev.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => { contactForm.photo = reader.result }
|
||||
reader.readAsDataURL(file)
|
||||
ev.target.value = ''
|
||||
}
|
||||
|
||||
async function saveContact() {
|
||||
if (!contactForm.value.display_name.trim()) return
|
||||
const payload = { ...contactForm }
|
||||
payload.categories = categoriesString.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
// Drop empty sub-rows
|
||||
payload.emails = payload.emails.filter(e => e.value.trim())
|
||||
payload.phones = payload.phones.filter(p => p.value.trim())
|
||||
payload.websites = payload.websites.filter(w => w.value.trim())
|
||||
payload.impp = payload.impp.filter(i => i.value.trim())
|
||||
payload.addresses = payload.addresses.filter(a =>
|
||||
a.street || a.city || a.postal_code || a.country || a.region || a.po_box)
|
||||
|
||||
try {
|
||||
if (editingContact.value) {
|
||||
await apiClient.put(`/contacts/${editingContact.value.id}`, contactForm.value)
|
||||
if (editingContactId.value) {
|
||||
await apiClient.put(`/contacts/${editingContactId.value}`, payload)
|
||||
} else {
|
||||
await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, contactForm.value)
|
||||
await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, payload)
|
||||
}
|
||||
showContactForm.value = false
|
||||
await loadContacts()
|
||||
showContactDialog.value = false
|
||||
await loadBooks()
|
||||
await loadContacts()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deleteTarget = ref(null)
|
||||
|
||||
function confirmDeleteContact(contact) {
|
||||
deleteTarget.value = contact
|
||||
showDeleteConfirm.value = true
|
||||
function confirmDeleteContact(row) {
|
||||
deleteContactTarget.value = row
|
||||
confirmDeleteContactDialog.value = true
|
||||
}
|
||||
|
||||
async function doDeleteContact() {
|
||||
if (!deleteTarget.value) return
|
||||
await apiClient.delete(`/contacts/${deleteTarget.value.id}`)
|
||||
showDeleteConfirm.value = false
|
||||
await loadContacts()
|
||||
async function deleteContact() {
|
||||
if (!deleteContactTarget.value) return
|
||||
await apiClient.delete(`/contacts/${deleteContactTarget.value.id}`)
|
||||
confirmDeleteContactDialog.value = false
|
||||
await loadBooks()
|
||||
await loadContacts()
|
||||
}
|
||||
|
||||
onMounted(loadBooks)
|
||||
// Live-Refresh via SSE
|
||||
let eventSource = null
|
||||
let reloadTimer = null
|
||||
|
||||
function scheduleReload() {
|
||||
if (reloadTimer) return
|
||||
reloadTimer = setTimeout(async () => {
|
||||
reloadTimer = null
|
||||
await loadBooks()
|
||||
await loadContacts()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBooks()
|
||||
await loadContacts()
|
||||
if (auth.accessToken) {
|
||||
try {
|
||||
eventSource = new EventSource(`/api/sync/events?token=${encodeURIComponent(auth.accessToken)}`)
|
||||
eventSource.addEventListener('addressbook', scheduleReload)
|
||||
eventSource.addEventListener('message', scheduleReload)
|
||||
eventSource.onerror = () => { /* auto-reconnects */ }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (reloadTimer) clearTimeout(reloadTimer)
|
||||
if (eventSource) eventSource.close()
|
||||
})
|
||||
|
||||
watch(selectedBookId, loadContacts)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.view-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
||||
.view-header h2 { margin: 0; }
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.contacts-layout { display: flex; gap: 1rem; }
|
||||
.books-sidebar { width: 220px; flex-shrink: 0; }
|
||||
.book-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; }
|
||||
.book-item:hover { background: var(--p-surface-100); }
|
||||
.book-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
|
||||
.book-item .count { margin-left: auto; color: var(--p-text-muted-color); font-size: 0.75rem; }
|
||||
.contacts-main { flex: 1; }
|
||||
.search-bar { margin-bottom: 1rem; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
.contacts-layout { display: flex; gap: 1rem; align-items: flex-start; }
|
||||
.books-sidebar { width: 260px; flex-shrink: 0; }
|
||||
.books-sidebar h4 { margin: 0 0 0.5rem; font-size: 0.85rem; text-transform: uppercase; color: var(--p-text-muted-color); }
|
||||
.book-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem;
|
||||
border-radius: 4px; cursor: pointer; font-size: 0.875rem; }
|
||||
.book-item:hover { background: var(--p-surface-50); }
|
||||
.book-item.active { background: var(--p-primary-50); }
|
||||
.book-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
|
||||
.book-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.shared-label { color: var(--p-text-muted-color); font-size: 0.7rem; }
|
||||
.count { color: var(--p-text-muted-color); font-size: 0.8rem; }
|
||||
.book-menu { opacity: 0; transition: opacity .15s; }
|
||||
.book-item:hover .book-menu { opacity: 1; }
|
||||
.contacts-main { flex: 1; min-width: 0; }
|
||||
.search-bar { margin-bottom: 0.75rem; }
|
||||
.empty { text-align: center; color: var(--p-text-muted-color); padding: 2rem; }
|
||||
.contact-row { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.avatar { width: 36px; height: 36px; border-radius: 50%; background: #888; color: white;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: bold; flex-shrink: 0; overflow: hidden; }
|
||||
.avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.avatar.large { width: 96px; height: 96px; font-size: 2rem; }
|
||||
.contact-name { display: flex; flex-direction: column; }
|
||||
.contact-name small { color: var(--p-text-muted-color); font-size: 0.75rem; }
|
||||
.field { margin-bottom: 0.75rem; }
|
||||
.field label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.875rem; }
|
||||
.field-row { display: flex; gap: 0.75rem; align-items: flex-end; }
|
||||
.field-row .field { flex: 1; margin-bottom: 0.75rem; }
|
||||
.photo-row { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
||||
.multi-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.4rem; }
|
||||
.address-card { border: 1px solid var(--p-surface-200); padding: 0.75rem; border-radius: 6px; margin-bottom: 0.75rem; }
|
||||
.share-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.user-search-popup { position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
|
||||
background: white; border: 1px solid var(--p-surface-200);
|
||||
border-radius: 4px; max-height: 160px; overflow-y: auto;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem; }
|
||||
.user-result:hover { background: var(--p-primary-50); }
|
||||
.existing-shares { margin-top: 0.5rem; }
|
||||
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; flex-wrap: wrap; }
|
||||
.share-perm-item.editing { background: var(--p-surface-50); padding: 0.5rem; border-radius: 4px; }
|
||||
.perm-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
|
||||
.carddav-block { border-top: 1px solid var(--p-surface-200); padding-top: 1rem; margin-top: 1rem; }
|
||||
.url-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap; }
|
||||
.url-row strong { min-width: 160px; font-size: 0.8rem; }
|
||||
.url-row code { background: var(--p-surface-100); padding: 0.25rem 0.5rem; border-radius: 4px;
|
||||
font-size: 0.8rem; word-break: break-all; flex: 1; }
|
||||
.hint { font-size: 0.75rem; color: var(--p-text-muted-color); font-style: italic; }
|
||||
:deep(.clickable) { cursor: pointer; }
|
||||
h5 { margin: 0.5rem 0 0.25rem; font-size: 0.85rem; text-transform: uppercase; color: var(--p-text-muted-color); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user