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:
Stefan Hacker
2026-04-12 15:16:01 +02:00
parent fbf10197d7
commit 9c102823e4
6 changed files with 1401 additions and 208 deletions
+633 -97
View File
@@ -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>