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,123 @@
|
||||
<template>
|
||||
<div class="share-container">
|
||||
<div class="share-card">
|
||||
<i class="pi pi-cloud" style="font-size: 2rem; color: var(--p-primary-color)"></i>
|
||||
|
||||
<div v-if="loading" class="share-loading">
|
||||
<i class="pi pi-spin pi-spinner" style="font-size: 1.5rem"></i>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="share-error">
|
||||
<i class="pi pi-times-circle" style="font-size: 2rem; color: var(--p-red-500)"></i>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="fileInfo" class="share-info">
|
||||
<h2>{{ fileInfo.name }}</h2>
|
||||
<p class="file-size" v-if="fileInfo.size">{{ formatSize(fileInfo.size) }}</p>
|
||||
|
||||
<div v-if="fileInfo.has_password && !authenticated" class="password-form">
|
||||
<p>Diese Datei ist passwortgeschuetzt.</p>
|
||||
<div class="field">
|
||||
<Password v-model="password" placeholder="Passwort eingeben" :feedback="false" toggle-mask fluid />
|
||||
</div>
|
||||
<Message v-if="authError" severity="error" :closable="false">{{ authError }}</Message>
|
||||
<Button label="Entsperren" @click="verifyPassword" :loading="verifying" fluid />
|
||||
</div>
|
||||
|
||||
<div v-else class="download-section">
|
||||
<Button
|
||||
label="Herunterladen"
|
||||
icon="pi pi-download"
|
||||
size="large"
|
||||
@click="downloadFile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
import Button from 'primevue/button'
|
||||
import Password from 'primevue/password'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
const route = useRoute()
|
||||
const token = route.params.token
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const fileInfo = ref(null)
|
||||
const password = ref('')
|
||||
const authenticated = ref(false)
|
||||
const authError = ref('')
|
||||
const verifying = ref(false)
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return ''
|
||||
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]}`
|
||||
}
|
||||
|
||||
async function loadInfo() {
|
||||
try {
|
||||
const res = await axios.get(`/api/share/${token}/info`)
|
||||
fileInfo.value = res.data
|
||||
if (!res.data.has_password) authenticated.value = true
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Link nicht gefunden oder abgelaufen'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPassword() {
|
||||
authError.value = ''
|
||||
verifying.value = true
|
||||
try {
|
||||
await axios.post(`/api/share/${token}/verify`, { password: password.value })
|
||||
authenticated.value = true
|
||||
} catch (err) {
|
||||
authError.value = err.response?.data?.error || 'Falsches Passwort'
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
let url = `/api/share/${token}/download`
|
||||
if (fileInfo.value?.has_password && password.value) {
|
||||
url += `?password=${encodeURIComponent(password.value)}`
|
||||
}
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
onMounted(loadInfo)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.share-container {
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--p-surface-50);
|
||||
}
|
||||
.share-card {
|
||||
background: var(--p-surface-0); border-radius: 12px; padding: 3rem;
|
||||
text-align: center; max-width: 450px; width: 100%;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.share-card h2 { margin: 1rem 0 0.25rem; font-size: 1.25rem; }
|
||||
.file-size { color: var(--p-text-muted-color); margin-bottom: 1.5rem; }
|
||||
.password-form { text-align: left; margin-top: 1.5rem; }
|
||||
.password-form p { margin-bottom: 1rem; color: var(--p-text-muted-color); }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.download-section { margin-top: 1.5rem; }
|
||||
.share-loading, .share-error { margin-top: 1.5rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user