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,376 @@
|
||||
<template>
|
||||
<div class="view-container">
|
||||
<div class="view-header">
|
||||
<h2>Kalender</h2>
|
||||
<div class="header-actions">
|
||||
<Button icon="pi pi-plus" label="Neuer Kalender" size="small" outlined @click="showNewCalendar = true" />
|
||||
<Button icon="pi pi-plus" label="Neues Event" size="small" @click="openNewEvent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-layout">
|
||||
<aside class="calendar-sidebar">
|
||||
<div v-for="cal in calendars" :key="cal.id" class="calendar-item">
|
||||
<div class="calendar-color" :style="{ background: cal.color }"></div>
|
||||
<span>{{ cal.name }}</span>
|
||||
<span v-if="cal.owner_name" class="shared-label">({{ cal.owner_name }})</span>
|
||||
<Button icon="pi pi-ellipsis-v" text size="small" @click="openCalendarMenu(cal, $event)" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="calendar-main">
|
||||
<div class="cal-nav">
|
||||
<Button icon="pi pi-chevron-left" text @click="changeMonth(-1)" />
|
||||
<h3>{{ currentMonthLabel }}</h3>
|
||||
<Button icon="pi pi-chevron-right" text @click="changeMonth(1)" />
|
||||
<Button label="Heute" text size="small" @click="goToday" />
|
||||
</div>
|
||||
|
||||
<div class="cal-grid">
|
||||
<div class="cal-header" v-for="day in weekDays" :key="day">{{ day }}</div>
|
||||
<div
|
||||
v-for="(cell, i) in calendarCells"
|
||||
:key="i"
|
||||
class="cal-cell"
|
||||
:class="{ 'other-month': !cell.currentMonth, 'today': cell.isToday }"
|
||||
@click="openNewEventOnDate(cell.date)"
|
||||
>
|
||||
<span class="cell-day">{{ cell.day }}</span>
|
||||
<div v-for="evt in cell.events" :key="evt.id" class="cell-event"
|
||||
:style="{ background: evt.color }" @click.stop="openEditEvent(evt)">
|
||||
{{ evt.summary }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Calendar Dialog -->
|
||||
<Dialog v-model:visible="showNewCalendar" header="Neuer Kalender" modal :style="{ width: '400px' }">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<InputText v-model="newCalName" fluid autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Farbe</label>
|
||||
<InputText v-model="newCalColor" type="color" style="width: 60px; height: 36px" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Abbrechen" text @click="showNewCalendar = false" />
|
||||
<Button label="Erstellen" @click="createCalendar" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Event Dialog -->
|
||||
<Dialog v-model:visible="showEventDialog" :header="editingEvent ? 'Event bearbeiten' : 'Neues Event'" modal :style="{ width: '500px' }">
|
||||
<div class="field">
|
||||
<label>Titel</label>
|
||||
<InputText v-model="eventForm.summary" fluid autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Kalender</label>
|
||||
<Select v-model="eventForm.calendar_id" :options="ownCalendars" optionLabel="name" optionValue="id" fluid />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Start</label>
|
||||
<InputText v-model="eventForm.dtstart" type="datetime-local" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ende</label>
|
||||
<InputText v-model="eventForm.dtend" type="datetime-local" fluid />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><input type="checkbox" v-model="eventForm.all_day" /> Ganztaegig</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button v-if="editingEvent" label="Loeschen" severity="danger" text @click="deleteEvent" />
|
||||
<Button label="Abbrechen" text @click="showEventDialog = false" />
|
||||
<Button :label="editingEvent ? 'Speichern' : 'Erstellen'" @click="saveEvent" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Calendar Context Menu -->
|
||||
<Dialog v-model:visible="showCalMenu" header="Kalender-Optionen" modal :style="{ width: '400px' }">
|
||||
<div v-if="selectedCal" class="cal-menu-content">
|
||||
<p><strong>{{ selectedCal.name }}</strong></p>
|
||||
|
||||
<div class="field">
|
||||
<label>Mit Benutzer teilen</label>
|
||||
<div class="share-row">
|
||||
<InputText v-model="shareUsername" placeholder="Benutzername" fluid />
|
||||
<Select v-model="sharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
|
||||
<Button label="Teilen" size="small" @click="shareCalendar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedCal.permission === 'owner'" class="field">
|
||||
<Button label="iCal-Link generieren" icon="pi pi-link" outlined size="small" @click="generateIcalLink" />
|
||||
<div v-if="icalUrl" class="ical-url">
|
||||
<code>{{ fullIcalUrl }}</code>
|
||||
<Button icon="pi pi-copy" text size="small" @click="copyIcal" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button v-if="selectedCal.permission === 'owner'" label="Kalender loeschen"
|
||||
severity="danger" text size="small" @click="deleteCalendar" />
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, 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 Select from 'primevue/select'
|
||||
|
||||
const toast = useToast()
|
||||
const calendars = ref([])
|
||||
const allEvents = ref([])
|
||||
const currentDate = ref(new Date())
|
||||
|
||||
const showNewCalendar = ref(false)
|
||||
const newCalName = ref('')
|
||||
const newCalColor = ref('#3788d8')
|
||||
|
||||
const showEventDialog = ref(false)
|
||||
const editingEvent = ref(null)
|
||||
const eventForm = ref({ summary: '', calendar_id: null, dtstart: '', dtend: '', all_day: false })
|
||||
|
||||
const showCalMenu = ref(false)
|
||||
const selectedCal = ref(null)
|
||||
const shareUsername = ref('')
|
||||
const sharePermission = ref('read')
|
||||
const icalUrl = ref('')
|
||||
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
|
||||
|
||||
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
|
||||
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
|
||||
const fullIcalUrl = computed(() => icalUrl.value ? `${window.location.origin}${icalUrl.value}` : '')
|
||||
|
||||
const currentMonthLabel = computed(() => {
|
||||
return currentDate.value.toLocaleString('de-DE', { month: 'long', year: 'numeric' })
|
||||
})
|
||||
|
||||
const calendarCells = computed(() => {
|
||||
const year = currentDate.value.getFullYear()
|
||||
const month = currentDate.value.getMonth()
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const lastDay = new Date(year, month + 1, 0)
|
||||
const today = new Date()
|
||||
|
||||
let startDay = (firstDay.getDay() + 6) % 7
|
||||
const cells = []
|
||||
|
||||
for (let i = startDay - 1; i >= 0; i--) {
|
||||
const d = new Date(year, month, -i)
|
||||
cells.push({ date: d, day: d.getDate(), currentMonth: false, isToday: false, events: [] })
|
||||
}
|
||||
|
||||
for (let d = 1; d <= lastDay.getDate(); d++) {
|
||||
const date = new Date(year, month, d)
|
||||
const isToday = date.toDateString() === today.toDateString()
|
||||
const dayEvents = allEvents.value.filter(e => {
|
||||
const start = new Date(e.dtstart)
|
||||
return start.getFullYear() === year && start.getMonth() === month && start.getDate() === d
|
||||
})
|
||||
cells.push({ date, day: d, currentMonth: true, isToday, events: dayEvents })
|
||||
}
|
||||
|
||||
while (cells.length < 42) {
|
||||
const d = new Date(year, month + 1, cells.length - startDay - lastDay.getDate() + 1)
|
||||
cells.push({ date: d, day: d.getDate(), currentMonth: false, isToday: false, events: [] })
|
||||
}
|
||||
|
||||
return cells
|
||||
})
|
||||
|
||||
function changeMonth(delta) {
|
||||
const d = new Date(currentDate.value)
|
||||
d.setMonth(d.getMonth() + delta)
|
||||
currentDate.value = d
|
||||
loadEvents()
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
currentDate.value = new Date()
|
||||
loadEvents()
|
||||
}
|
||||
|
||||
async function loadCalendars() {
|
||||
const res = await apiClient.get('/calendars')
|
||||
calendars.value = res.data
|
||||
if (!calendars.value.length) {
|
||||
await apiClient.post('/calendars', { name: 'Mein Kalender', color: '#3788d8' })
|
||||
const res2 = await apiClient.get('/calendars')
|
||||
calendars.value = res2.data
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
allEvents.value = []
|
||||
for (const cal of calendars.value) {
|
||||
const year = currentDate.value.getFullYear()
|
||||
const month = currentDate.value.getMonth()
|
||||
const start = new Date(year, month - 1, 1).toISOString()
|
||||
const end = new Date(year, month + 2, 0).toISOString()
|
||||
try {
|
||||
const res = await apiClient.get(`/calendars/${cal.id}/events`, { params: { start, end } })
|
||||
allEvents.value.push(...res.data.map(e => ({ ...e, color: cal.color, calendarName: cal.name })))
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function createCalendar() {
|
||||
if (!newCalName.value.trim()) return
|
||||
await apiClient.post('/calendars', { name: newCalName.value.trim(), color: newCalColor.value })
|
||||
showNewCalendar.value = false
|
||||
newCalName.value = ''
|
||||
await loadCalendars()
|
||||
}
|
||||
|
||||
function openNewEvent() {
|
||||
editingEvent.value = null
|
||||
const now = new Date()
|
||||
const later = new Date(now.getTime() + 3600000)
|
||||
eventForm.value = {
|
||||
summary: '',
|
||||
calendar_id: ownCalendars.value[0]?.id,
|
||||
dtstart: toLocalISO(now),
|
||||
dtend: toLocalISO(later),
|
||||
all_day: false,
|
||||
}
|
||||
showEventDialog.value = true
|
||||
}
|
||||
|
||||
function openNewEventOnDate(date) {
|
||||
editingEvent.value = null
|
||||
const start = new Date(date); start.setHours(9, 0)
|
||||
const end = new Date(date); end.setHours(10, 0)
|
||||
eventForm.value = {
|
||||
summary: '',
|
||||
calendar_id: ownCalendars.value[0]?.id,
|
||||
dtstart: toLocalISO(start),
|
||||
dtend: toLocalISO(end),
|
||||
all_day: false,
|
||||
}
|
||||
showEventDialog.value = true
|
||||
}
|
||||
|
||||
function openEditEvent(evt) {
|
||||
editingEvent.value = evt
|
||||
eventForm.value = {
|
||||
summary: evt.summary,
|
||||
calendar_id: evt.calendar_id,
|
||||
dtstart: toLocalISO(new Date(evt.dtstart)),
|
||||
dtend: toLocalISO(new Date(evt.dtend)),
|
||||
all_day: evt.all_day,
|
||||
}
|
||||
showEventDialog.value = true
|
||||
}
|
||||
|
||||
async function saveEvent() {
|
||||
if (!eventForm.value.summary.trim()) return
|
||||
const payload = { ...eventForm.value }
|
||||
|
||||
if (editingEvent.value) {
|
||||
await apiClient.put(`/events/${editingEvent.value.id}`, payload)
|
||||
} else {
|
||||
await apiClient.post(`/calendars/${payload.calendar_id}/events`, payload)
|
||||
}
|
||||
showEventDialog.value = false
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
async function deleteEvent() {
|
||||
if (!editingEvent.value) return
|
||||
await apiClient.delete(`/events/${editingEvent.value.id}`)
|
||||
showEventDialog.value = false
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
function openCalendarMenu(cal) {
|
||||
selectedCal.value = cal
|
||||
icalUrl.value = ''
|
||||
showCalMenu.value = true
|
||||
}
|
||||
|
||||
async function shareCalendar() {
|
||||
if (!shareUsername.value.trim() || !selectedCal.value) return
|
||||
try {
|
||||
await apiClient.post(`/calendars/${selectedCal.value.id}/share`, {
|
||||
username: shareUsername.value.trim(), permission: sharePermission.value,
|
||||
})
|
||||
toast.add({ severity: 'success', summary: 'Kalender geteilt', life: 3000 })
|
||||
shareUsername.value = ''
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function generateIcalLink() {
|
||||
if (!selectedCal.value) return
|
||||
const res = await apiClient.post(`/calendars/${selectedCal.value.id}/ical-link`)
|
||||
icalUrl.value = res.data.ical_url
|
||||
}
|
||||
|
||||
function copyIcal() {
|
||||
navigator.clipboard.writeText(fullIcalUrl.value)
|
||||
toast.add({ severity: 'info', summary: 'Link kopiert', life: 2000 })
|
||||
}
|
||||
|
||||
async function deleteCalendar() {
|
||||
if (!selectedCal.value) return
|
||||
await apiClient.delete(`/calendars/${selectedCal.value.id}`)
|
||||
showCalMenu.value = false
|
||||
await loadCalendars()
|
||||
await loadEvents()
|
||||
}
|
||||
|
||||
function toLocalISO(date) {
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCalendars()
|
||||
await loadEvents()
|
||||
})
|
||||
</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; }
|
||||
.calendar-layout { display: flex; gap: 1rem; }
|
||||
.calendar-sidebar { width: 220px; flex-shrink: 0; }
|
||||
.calendar-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; }
|
||||
.calendar-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
|
||||
.shared-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
|
||||
.calendar-main { flex: 1; }
|
||||
.cal-nav { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
.cal-nav h3 { margin: 0; min-width: 180px; text-align: center; }
|
||||
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); border: 1px solid var(--p-surface-200); }
|
||||
.cal-header { padding: 0.5rem; text-align: center; font-weight: 600; font-size: 0.8rem; background: var(--p-surface-100); border-bottom: 1px solid var(--p-surface-200); }
|
||||
.cal-cell { min-height: 80px; padding: 0.25rem; border: 1px solid var(--p-surface-100); cursor: pointer; font-size: 0.8rem; }
|
||||
.cal-cell:hover { background: var(--p-surface-50); }
|
||||
.cal-cell.other-month { opacity: 0.4; }
|
||||
.cal-cell.today { background: var(--p-primary-50); }
|
||||
.cell-day { font-weight: 500; font-size: 0.75rem; }
|
||||
.cell-event { font-size: 0.7rem; padding: 1px 4px; border-radius: 3px; color: white; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||
.field-row { display: flex; gap: 1rem; }
|
||||
.field-row .field { flex: 1; }
|
||||
.share-row { display: flex; gap: 0.5rem; align-items: flex-start; }
|
||||
.ical-url { margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.ical-url code { font-size: 0.75rem; word-break: break-all; }
|
||||
.cal-menu-content p { margin: 0 0 1rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user