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,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>
|
||||
Reference in New Issue
Block a user