feat: Mini-Cloud Plattform - komplette Implementierung Phase 0-8
Selbstgehostete Web-Cloud mit Dateiverwaltung, Kalender, Kontakte, Email-Webclient, Office-Viewer und Passwort-Manager. Backend (Flask/Python): - JWT-Auth mit Access/Refresh Tokens, Benutzerverwaltung - Dateien: Upload/Download, Ordner, Berechtigungen, Share-Links - Kalender: CRUD, Teilen, iCal-Export, CalDAV well-known URLs - Kontakte: Adressbuecher, vCard-Export, Teilen - Email: IMAP/SMTP-Proxy, Multi-Account - Office-Viewer: DOCX/XLSX/PPTX/PDF Vorschau - Passwort-Manager: AES-256-GCM clientseitig, KeePass-Import - Sync-API fuer Desktop/Mobile-Clients - SQLite mit WAL-Modus Frontend (Vue 3 + PrimeVue): - Datei-Explorer mit Breadcrumbs und Share-Dialogen - Monatskalender mit Event-Verwaltung - Kontaktliste mit Adressbuch-Sidebar - Email-Client mit 3-Spalten-Layout - Passwort-Manager mit TOTP und Passwort-Generator - Admin-Panel, Settings, oeffentliche Share-Seite Docker: Multi-Stage Build, Bind Mounts (keine Volumes) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Kontakte</h2>
|
||||
<div class="header-actions">
|
||||
<Button icon="pi pi-book" label="Neues Adressbuch" size="small" outlined @click="showNewBook = true" />
|
||||
<Button icon="pi pi-user-plus" label="Neuer Kontakt" size="small" @click="openNewContact" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contacts-layout">
|
||||
<aside class="books-sidebar">
|
||||
<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="count">{{ book.contact_count }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="contacts-main">
|
||||
<div class="search-bar">
|
||||
<InputText v-model="searchQuery" placeholder="Kontakte suchen..." fluid @input="loadContacts" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="contacts" :loading="loading" striped-rows @row-click="openEditContact">
|
||||
<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="" style="width: 80px">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click.stop="deleteContact(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Address Book -->
|
||||
<Dialog v-model:visible="showNewBook" header="Neues Adressbuch" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model="newBookName" fluid autofocus @keyup.enter="createBook" />
|
||||
</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 />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showContactForm = false" />
|
||||
<Button :label="editingContact ? 'Speichern' : 'Erstellen'" @click="saveContact" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
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 DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
|
||||
const toast = useToast()
|
||||
const addressBooks = ref([])
|
||||
const contacts = ref([])
|
||||
const selectedBookId = ref(null)
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const showNewBook = ref(false)
|
||||
const newBookName = ref('')
|
||||
|
||||
const showContactForm = ref(false)
|
||||
const editingContact = ref(null)
|
||||
const contactForm = ref({ display_name: '', email: '', phone: '', organization: '', notes: '' })
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
contacts.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectBook(id) {
|
||||
selectedBookId.value = id
|
||||
loadContacts()
|
||||
}
|
||||
|
||||
async function createBook() {
|
||||
if (!newBookName.value.trim()) return
|
||||
await apiClient.post('/addressbooks', { name: newBookName.value.trim() })
|
||||
showNewBook.value = false
|
||||
newBookName.value = ''
|
||||
await loadBooks()
|
||||
}
|
||||
|
||||
function openNewContact() {
|
||||
editingContact.value = null
|
||||
contactForm.value = { display_name: '', email: '', phone: '', organization: '', notes: '' }
|
||||
showContactForm.value = true
|
||||
}
|
||||
|
||||
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: '',
|
||||
}
|
||||
showContactForm.value = true
|
||||
}
|
||||
|
||||
async function saveContact() {
|
||||
if (!contactForm.value.display_name.trim()) return
|
||||
try {
|
||||
if (editingContact.value) {
|
||||
await apiClient.put(`/contacts/${editingContact.value.id}`, contactForm.value)
|
||||
} else {
|
||||
await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, contactForm.value)
|
||||
}
|
||||
showContactForm.value = false
|
||||
await loadContacts()
|
||||
await loadBooks()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContact(contact) {
|
||||
await apiClient.delete(`/contacts/${contact.id}`)
|
||||
await loadContacts()
|
||||
await loadBooks()
|
||||
}
|
||||
|
||||
onMounted(loadBooks)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.view-container { padding: 1.5rem; }
|
||||
.view-header { display: flex; align-items: center; justify-content: space-between; 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; }
|
||||
.empty { text-align: center; color: var(--p-text-muted-color); padding: 2rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user