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:
Stefan Hacker
2026-04-11 14:53:28 +02:00
parent d4f7e90d0c
commit 62f550c373
56 changed files with 8047 additions and 0 deletions
+409
View File
@@ -0,0 +1,409 @@
<template>
<div class="view-container">
<div class="view-header">
<div class="breadcrumb">
<a @click="navigateTo(null)" class="crumb">Dateien</a>
<template v-for="item in filesStore.breadcrumb" :key="item.id">
<i class="pi pi-angle-right crumb-sep"></i>
<a @click="navigateTo(item.id)" class="crumb">{{ item.name }}</a>
</template>
</div>
<div class="header-actions">
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" />
<Button icon="pi pi-upload" label="Hochladen" size="small" @click="triggerUpload" />
<input ref="fileInput" type="file" multiple hidden @change="handleUpload" />
</div>
</div>
<DataTable
:value="filesStore.files"
:loading="filesStore.loading"
@row-dblclick="handleDoubleClick"
striped-rows
removable-sort
class="files-table"
>
<template #empty>
<div class="empty-state">
<i class="pi pi-folder-open" style="font-size: 2rem; color: var(--p-surface-400)"></i>
<p>Dieser Ordner ist leer</p>
</div>
</template>
<Column field="name" header="Name" sortable style="min-width: 300px">
<template #body="{ data }">
<div class="file-name" @click="data.is_folder && navigateTo(data.id)">
<i :class="fileIcon(data)" class="file-icon"></i>
<span>{{ data.name }}</span>
<Tag v-if="data.shared" value="Geteilt" severity="info" class="shared-tag" />
</div>
</template>
</Column>
<Column field="size" header="Groesse" sortable style="width: 120px">
<template #body="{ data }">
{{ data.is_folder ? '' : formatSize(data.size) }}
</template>
</Column>
<Column field="updated_at" header="Geaendert" sortable style="width: 180px">
<template #body="{ data }">
{{ formatDate(data.updated_at) }}
</template>
</Column>
<Column header="" style="width: 140px">
<template #body="{ data }">
<div class="row-actions">
<Button
v-if="!data.is_folder"
icon="pi pi-download"
text rounded size="small"
@click="downloadFile(data)"
/>
<Button
icon="pi pi-share-alt"
text rounded size="small"
@click="openShare(data)"
/>
<Button
icon="pi pi-pencil"
text rounded size="small"
@click="openRename(data)"
/>
<Button
icon="pi pi-trash"
text rounded size="small"
severity="danger"
@click="confirmDelete(data)"
/>
</div>
</template>
</Column>
</DataTable>
<!-- New Folder Dialog -->
<Dialog v-model:visible="showNewFolder" header="Neuer Ordner" modal :style="{ width: '400px' }">
<div class="field">
<label>Ordnername</label>
<InputText v-model="newFolderName" fluid autofocus @keyup.enter="createFolder" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showNewFolder = false" />
<Button label="Erstellen" @click="createFolder" />
</template>
</Dialog>
<!-- Rename Dialog -->
<Dialog v-model:visible="showRename" header="Umbenennen" modal :style="{ width: '400px' }">
<div class="field">
<label>Neuer Name</label>
<InputText v-model="renameName" fluid autofocus @keyup.enter="doRename" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showRename = false" />
<Button label="Umbenennen" @click="doRename" />
</template>
</Dialog>
<!-- Share Dialog -->
<Dialog v-model:visible="showShare" header="Teilen" modal :style="{ width: '500px' }">
<div v-if="shareFile" class="share-content">
<h4>{{ shareFile.name }}</h4>
<div class="share-form">
<div class="field">
<label>Passwort (optional)</label>
<Password v-model="sharePassword" :feedback="false" toggle-mask fluid />
</div>
<div class="field">
<label>Ablaufdatum (optional)</label>
<InputText v-model="shareExpiry" type="date" fluid />
</div>
<Button label="Link erstellen" icon="pi pi-link" @click="createShare" :loading="shareLoading" />
</div>
<div v-if="shareLinks.length" class="existing-links">
<h4>Bestehende Links</h4>
<div v-for="link in shareLinks" :key="link.id" class="share-link-item">
<div class="link-info">
<code>{{ window.location.origin }}/share/{{ link.token }}</code>
<small>
{{ link.download_count }} Downloads
<template v-if="link.expires_at"> | Bis {{ formatDate(link.expires_at) }}</template>
<template v-if="link.has_password"> | Passwortgeschuetzt</template>
</small>
</div>
<div class="link-actions">
<Button icon="pi pi-copy" text size="small" @click="copyLink(link.token)" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeShare(link.token)" />
</div>
</div>
</div>
</div>
</Dialog>
<!-- Delete Confirm -->
<Dialog v-model:visible="showDeleteConfirm" header="Loeschen bestaetigen" modal :style="{ width: '400px' }">
<p>Moechtest du <strong>{{ deleteTarget?.name }}</strong> wirklich loeschen?</p>
<p v-if="deleteTarget?.is_folder" class="text-warn">Alle Dateien in diesem Ordner werden ebenfalls geloescht!</p>
<template #footer>
<Button label="Abbrechen" text @click="showDeleteConfirm = false" />
<Button label="Loeschen" severity="danger" @click="doDelete" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useFilesStore } from '../stores/files'
import { useToast } from 'primevue/usetoast'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Tag from 'primevue/tag'
const route = useRoute()
const router = useRouter()
const filesStore = useFilesStore()
const toast = useToast()
const fileInput = ref(null)
const showNewFolder = ref(false)
const newFolderName = ref('')
const showRename = ref(false)
const renameName = ref('')
const renameTarget = ref(null)
const showShare = ref(false)
const shareFile = ref(null)
const sharePassword = ref('')
const shareExpiry = ref('')
const shareLinks = ref([])
const shareLoading = ref(false)
const showDeleteConfirm = ref(false)
const deleteTarget = ref(null)
function currentParentId() {
const id = route.params.folderId
return id ? parseInt(id) : null
}
function navigateTo(folderId) {
if (folderId) {
router.push(`/files/${folderId}`)
} else {
router.push('/files')
}
}
function handleDoubleClick(event) {
const data = event.data
if (data.is_folder) {
navigateTo(data.id)
} else {
downloadFile(data)
}
}
function fileIcon(data) {
if (data.is_folder) return 'pi pi-folder'
const mime = data.mime_type || ''
if (mime.startsWith('image/')) return 'pi pi-image'
if (mime.startsWith('video/')) return 'pi pi-video'
if (mime.startsWith('audio/')) return 'pi pi-volume-up'
if (mime.includes('pdf')) return 'pi pi-file-pdf'
if (mime.includes('word') || mime.includes('document')) return 'pi pi-file-word'
if (mime.includes('sheet') || mime.includes('excel')) return 'pi pi-file-excel'
if (mime.includes('presentation') || mime.includes('powerpoint')) return 'pi pi-file'
return 'pi pi-file'
}
function formatSize(bytes) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
function formatDate(iso) {
if (!iso) return ''
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function triggerUpload() {
fileInput.value?.click()
}
async function handleUpload(event) {
const uploadFiles = event.target.files
if (!uploadFiles.length) return
for (const file of uploadFiles) {
try {
await filesStore.uploadFile(file, currentParentId())
toast.add({ severity: 'success', summary: `${file.name} hochgeladen`, life: 3000 })
} catch (err) {
toast.add({ severity: 'error', summary: `Fehler: ${file.name}`, detail: err.response?.data?.error, life: 5000 })
}
}
event.target.value = ''
}
async function createFolder() {
if (!newFolderName.value.trim()) return
try {
await filesStore.createFolder(newFolderName.value.trim(), currentParentId())
showNewFolder.value = false
newFolderName.value = ''
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
function downloadFile(data) {
const url = filesStore.downloadUrl(data.id)
const a = document.createElement('a')
a.href = url
a.download = data.name
a.click()
}
function openRename(data) {
renameTarget.value = data
renameName.value = data.name
showRename.value = true
}
async function doRename() {
if (!renameName.value.trim() || !renameTarget.value) return
try {
await filesStore.renameFile(renameTarget.value.id, renameName.value.trim())
showRename.value = false
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function openShare(data) {
shareFile.value = data
sharePassword.value = ''
shareExpiry.value = ''
showShare.value = true
try {
shareLinks.value = await filesStore.getShareLinks(data.id)
} catch {
shareLinks.value = []
}
}
async function createShare() {
if (!shareFile.value) return
shareLoading.value = true
try {
const opts = {}
if (sharePassword.value) opts.password = sharePassword.value
if (shareExpiry.value) opts.expires_at = shareExpiry.value
await filesStore.createShareLink(shareFile.value.id, opts)
shareLinks.value = await filesStore.getShareLinks(shareFile.value.id)
sharePassword.value = ''
shareExpiry.value = ''
toast.add({ severity: 'success', summary: 'Link erstellt', life: 3000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
} finally {
shareLoading.value = false
}
}
function copyLink(token) {
navigator.clipboard.writeText(`${window.location.origin}/share/${token}`)
toast.add({ severity: 'info', summary: 'Link kopiert', life: 2000 })
}
async function removeShare(token) {
try {
await filesStore.deleteShareLink(token)
shareLinks.value = shareLinks.value.filter(l => l.token !== token)
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
function confirmDelete(data) {
deleteTarget.value = data
showDeleteConfirm.value = true
}
async function doDelete() {
if (!deleteTarget.value) return
try {
await filesStore.deleteFile(deleteTarget.value.id)
showDeleteConfirm.value = false
toast.add({ severity: 'success', summary: 'Geloescht', life: 3000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
watch(() => route.params.folderId, () => {
filesStore.loadFiles(currentParentId())
})
onMounted(() => {
filesStore.loadFiles(currentParentId())
})
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.breadcrumb { display: flex; align-items: center; gap: 0.25rem; }
.crumb { cursor: pointer; color: var(--p-primary-color); font-weight: 500; }
.crumb:hover { text-decoration: underline; }
.crumb-sep { font-size: 0.75rem; color: var(--p-surface-400); }
.header-actions { display: flex; gap: 0.5rem; }
.file-name {
display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
}
.file-icon { font-size: 1.125rem; width: 1.25rem; text-align: center; }
.shared-tag { font-size: 0.7rem; }
.row-actions { display: flex; gap: 0; }
.empty-state {
display: flex; flex-direction: column; align-items: center;
gap: 0.5rem; padding: 3rem; color: var(--p-text-muted-color);
}
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
.share-content h4 { margin: 0 0 1rem; }
.share-form { margin-bottom: 1.5rem; }
.existing-links { border-top: 1px solid var(--p-surface-200); padding-top: 1rem; }
.share-link-item {
display: flex; justify-content: space-between; align-items: center;
padding: 0.5rem 0; border-bottom: 1px solid var(--p-surface-100);
}
.link-info { display: flex; flex-direction: column; gap: 0.25rem; }
.link-info code { font-size: 0.8rem; word-break: break-all; }
.link-info small { color: var(--p-text-muted-color); }
.link-actions { display: flex; }
.text-warn { color: var(--p-orange-500); font-size: 0.875rem; }
</style>