feat: Upload in freigegebene Ordner + Benachrichtigung

Share-Links fuer Ordner erlauben jetzt auch Uploads:

Backend:
- POST /share/<token>/upload - Datei in freigegebenen Ordner hochladen
- Passwort-Schutz wird bei Upload ebenfalls geprueft
- share_info gibt jetzt upload_allowed zurueck (true bei Ordner-Shares)
- Email-Benachrichtigung an den Ersteller wenn jemand eine Datei
  hochlaedt (Dateiname, Groesse, IP-Adresse)

Frontend (ShareView):
- Ordner-Shares zeigen jetzt eine Upload-Zone (Drag & Drop + Button)
- Fortschrittsbalken beim Upload mit Datei-Zaehler
- Erfolgs-/Fehlermeldung nach Upload
- Passwortgeschuetzte Ordner-Shares: erst entsperren, dann uploaden

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-11 18:35:10 +02:00
parent e811210977
commit 6a17748552
3 changed files with 199 additions and 12 deletions
+108 -12
View File
@@ -15,10 +15,11 @@
<div v-else-if="fileInfo" class="share-info">
<h2>{{ fileInfo.name }}</h2>
<p class="file-size" v-if="fileInfo.size">{{ formatSize(fileInfo.size) }}</p>
<p class="file-size" v-if="fileInfo.size && !fileInfo.is_folder">{{ formatSize(fileInfo.size) }}</p>
<Tag v-if="fileInfo.is_folder" value="Ordner" severity="info" />
<div v-if="fileInfo.has_password && !authenticated" class="password-form">
<p>Diese Datei ist passwortgeschuetzt.</p>
<p>Diese Freigabe ist passwortgeschuetzt.</p>
<div class="field">
<Password v-model="password" placeholder="Passwort eingeben" :feedback="false" toggle-mask fluid />
</div>
@@ -26,13 +27,37 @@
<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 v-else class="actions-section">
<!-- Download (files only) -->
<div v-if="!fileInfo.is_folder" class="action-block">
<Button
label="Herunterladen"
icon="pi pi-download"
size="large"
@click="downloadFile"
/>
</div>
<!-- Upload (folders only) -->
<div v-if="fileInfo.upload_allowed" class="action-block">
<div class="upload-area"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="onDrop"
:class="{ dragging: isDragging }">
<i class="pi pi-cloud-upload"></i>
<p>Dateien hierher ziehen oder</p>
<Button label="Dateien auswaehlen" icon="pi pi-upload" size="small" outlined @click="$refs.uploadInput.click()" />
<input ref="uploadInput" type="file" multiple hidden @change="handleUpload" />
</div>
<div v-if="uploading" class="upload-progress">
<ProgressBar :value="uploadPercent" />
<p>{{ uploadStatus }}</p>
</div>
<Message v-if="uploadSuccess" severity="success" :closable="false">{{ uploadSuccess }}</Message>
</div>
</div>
</div>
</div>
@@ -46,6 +71,8 @@ import axios from 'axios'
import Button from 'primevue/button'
import Password from 'primevue/password'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
import ProgressBar from 'primevue/progressbar'
const route = useRoute()
const token = route.params.token
@@ -58,6 +85,12 @@ const authenticated = ref(false)
const authError = ref('')
const verifying = ref(false)
const isDragging = ref(false)
const uploading = ref(false)
const uploadPercent = ref(0)
const uploadStatus = ref('')
const uploadSuccess = ref('')
function formatSize(bytes) {
if (!bytes) return ''
const units = ['B', 'KB', 'MB', 'GB']
@@ -100,6 +133,54 @@ function downloadFile() {
window.location.href = url
}
async function onDrop(e) {
isDragging.value = false
const files = Array.from(e.dataTransfer.files)
if (files.length) await uploadFiles(files)
}
async function handleUpload(event) {
const files = Array.from(event.target.files)
if (files.length) await uploadFiles(files)
event.target.value = ''
}
async function uploadFiles(files) {
uploading.value = true
uploadSuccess.value = ''
uploadPercent.value = 0
let uploaded = 0
let errors = 0
for (const file of files) {
uploadStatus.value = `${uploaded + 1} / ${files.length}: ${file.name}`
try {
const formData = new FormData()
formData.append('file', file)
if (fileInfo.value?.has_password && password.value) {
formData.append('password', password.value)
}
await axios.post(`/api/share/${token}/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
...(password.value ? { 'X-Share-Password': password.value } : {}),
},
})
uploaded++
} catch {
errors++
}
uploadPercent.value = Math.round(((uploaded + errors) / files.length) * 100)
}
uploading.value = false
if (errors) {
uploadSuccess.value = `${uploaded} hochgeladen, ${errors} fehlgeschlagen`
} else {
uploadSuccess.value = `${uploaded} Datei${uploaded !== 1 ? 'en' : ''} erfolgreich hochgeladen`
}
}
onMounted(loadInfo)
</script>
@@ -110,14 +191,29 @@ onMounted(loadInfo)
}
.share-card {
background: var(--p-surface-0); border-radius: 12px; padding: 3rem;
text-align: center; max-width: 450px; width: 100%;
text-align: center; max-width: 500px; 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; }
.file-size { color: var(--p-text-muted-color); margin-bottom: 1rem; }
.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; }
.actions-section { margin-top: 1.5rem; }
.action-block { margin-bottom: 1.5rem; }
.share-loading, .share-error { margin-top: 1.5rem; }
.upload-area {
border: 2px dashed var(--p-surface-300); border-radius: 10px;
padding: 2rem; text-align: center; cursor: pointer;
transition: all 0.2s;
}
.upload-area:hover, .upload-area.dragging {
border-color: var(--p-primary-color); background: var(--p-primary-50);
}
.upload-area i { font-size: 2rem; color: var(--p-surface-400); }
.upload-area.dragging i { color: var(--p-primary-color); }
.upload-area p { margin: 0.5rem 0; color: var(--p-text-muted-color); font-size: 0.9rem; }
.upload-progress { margin-top: 1rem; }
.upload-progress p { font-size: 0.85rem; color: var(--p-text-muted-color); margin-top: 0.5rem; }
</style>