feat: Drag & Drop Upload mit Ordner-Unterstuetzung

Dateien und komplette Verzeichnisse koennen jetzt hochgeladen werden:

Drag & Drop:
- Dateien per Drag & Drop auf den Datei-Explorer ziehen
- Ganze Ordner (inkl. Unterordner) per Drag & Drop hochladen
- Visuelles Overlay zeigt Drop-Zone an
- Ordnerstruktur wird automatisch auf dem Server nachgebildet

Buttons:
- "Dateien" Button: Mehrere Dateien auswaehlen (wie vorher)
- "Ordner" Button: Kompletten Ordner mit Unterordnern hochladen
  (nutzt webkitdirectory API)

Upload-Fortschritt:
- Fortschrittsbalken mit Datei-Zaehler waehrend des Uploads
- Fehlerhafte Uploads werden gezaehlt und gemeldet

Backend: /files/ensure-path Endpunkt erstellt verschachtelte
Ordnerstrukturen (z.B. "Docs/Work/Project") in einem Aufruf

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-11 18:17:39 +02:00
parent 10fde2396d
commit 61ce2ec244
2 changed files with 244 additions and 13 deletions

View File

@ -126,6 +126,46 @@ def create_folder():
return jsonify(folder.to_dict()), 201 return jsonify(folder.to_dict()), 201
def _ensure_folder_path(user_id, parent_id, path_parts):
"""Create nested folder structure. Returns the ID of the deepest folder."""
current_parent = parent_id
for part in path_parts:
part = part.strip()
if not part:
continue
existing = File.query.filter_by(
owner_id=user_id, parent_id=current_parent, name=part, is_folder=True
).first()
if existing:
current_parent = existing.id
else:
folder = File(owner_id=user_id, parent_id=current_parent, name=part, is_folder=True)
db.session.add(folder)
db.session.flush()
current_parent = folder.id
return current_parent
@api_bp.route('/files/ensure-path', methods=['POST'])
@token_required
def ensure_folder_path():
"""Create nested folder structure from a path string like 'Docs/Work/Project'.
Returns the ID of the deepest folder."""
user = request.current_user
data = request.get_json()
path = data.get('path', '').strip().strip('/')
parent_id = data.get('parent_id', None)
if not path:
return jsonify({'folder_id': parent_id}), 200
parts = [p for p in path.split('/') if p.strip()]
folder_id = _ensure_folder_path(user.id, parent_id, parts)
db.session.commit()
return jsonify({'folder_id': folder_id}), 200
# --- Upload --- # --- Upload ---
@api_bp.route('/files/upload', methods=['POST']) @api_bp.route('/files/upload', methods=['POST'])

View File

@ -1,5 +1,16 @@
<template> <template>
<div class="view-container"> <div class="view-container"
@dragover.prevent="onDragOver" @dragleave.prevent="onDragLeave"
@drop.prevent="onDrop">
<!-- Drop overlay -->
<div v-if="isDragging" class="drop-overlay">
<div class="drop-content">
<i class="pi pi-cloud-upload"></i>
<p>Dateien oder Ordner hier ablegen</p>
</div>
</div>
<div class="view-header"> <div class="view-header">
<div class="breadcrumb"> <div class="breadcrumb">
<a @click="navigateTo(null)" class="crumb">Dateien</a> <a @click="navigateTo(null)" class="crumb">Dateien</a>
@ -10,11 +21,22 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" /> <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" /> <Button icon="pi pi-upload" label="Dateien" size="small" @click="triggerUpload" />
<Button icon="pi pi-folder" label="Ordner" size="small" outlined @click="triggerFolderUpload" />
<input ref="fileInput" type="file" multiple hidden @change="handleUpload" /> <input ref="fileInput" type="file" multiple hidden @change="handleUpload" />
<input ref="folderInput" type="file" hidden webkitdirectory @change="handleFolderUpload" />
</div> </div>
</div> </div>
<!-- Upload progress -->
<div v-if="uploadQueue.length" class="upload-progress">
<div class="upload-info">
<i class="pi pi-spin pi-spinner"></i>
<span>{{ uploadCurrent }} / {{ uploadTotal }} Dateien hochgeladen</span>
</div>
<ProgressBar :value="uploadPercent" />
</div>
<DataTable <DataTable
:value="filesStore.files" :value="filesStore.files"
:loading="filesStore.loading" :loading="filesStore.loading"
@ -198,6 +220,7 @@ import InputText from 'primevue/inputtext'
import Password from 'primevue/password' import Password from 'primevue/password'
import Tag from 'primevue/tag' import Tag from 'primevue/tag'
import Select from 'primevue/select' import Select from 'primevue/select'
import ProgressBar from 'primevue/progressbar'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -205,6 +228,12 @@ const filesStore = useFilesStore()
const toast = useToast() const toast = useToast()
const fileInput = ref(null) const fileInput = ref(null)
const folderInput = ref(null)
const isDragging = ref(false)
const uploadQueue = ref([])
const uploadCurrent = ref(0)
const uploadTotal = ref(0)
const uploadPercent = ref(0)
const showNewFolder = ref(false) const showNewFolder = ref(false)
const newFolderName = ref('') const newFolderName = ref('')
const showRename = ref(false) const showRename = ref(false)
@ -285,19 +314,165 @@ function triggerUpload() {
fileInput.value?.click() fileInput.value?.click()
} }
async function handleUpload(event) { function triggerFolderUpload() {
const uploadFiles = event.target.files folderInput.value?.click()
if (!uploadFiles.length) return }
for (const file of uploadFiles) { async function handleUpload(event) {
try { const files = Array.from(event.target.files)
await filesStore.uploadFile(file, currentParentId()) if (!files.length) return
toast.add({ severity: 'success', summary: `${file.name} hochgeladen`, life: 3000 }) await uploadFilesWithProgress(files.map(f => ({ file: f, relativePath: null })))
} catch (err) { event.target.value = ''
toast.add({ severity: 'error', summary: `Fehler: ${file.name}`, detail: err.response?.data?.error, life: 5000 }) }
async function handleFolderUpload(event) {
const files = Array.from(event.target.files)
if (!files.length) return
// webkitRelativePath gives us "FolderName/sub/file.txt"
const items = files.map(f => ({
file: f,
relativePath: f.webkitRelativePath || null,
}))
await uploadFilesWithProgress(items)
event.target.value = ''
}
// --- Drag & Drop ---
let dragCounter = 0
function onDragOver(e) {
isDragging.value = true
}
function onDragLeave(e) {
dragCounter--
if (dragCounter <= 0) {
isDragging.value = false
dragCounter = 0
}
}
async function onDrop(e) {
isDragging.value = false
dragCounter = 0
const items = []
if (e.dataTransfer.items) {
// Use DataTransferItem API for directory support
const entries = []
for (const item of e.dataTransfer.items) {
if (item.kind === 'file') {
const entry = item.webkitGetAsEntry?.() || item.getAsEntry?.()
if (entry) {
entries.push(entry)
}
}
}
// Recursively read all entries
for (const entry of entries) {
await readEntry(entry, '', items)
}
} else {
// Fallback: flat file list
for (const file of e.dataTransfer.files) {
items.push({ file, relativePath: null })
} }
} }
event.target.value = ''
if (items.length) {
await uploadFilesWithProgress(items)
}
}
async function readEntry(entry, parentPath, items) {
if (entry.isFile) {
const file = await new Promise((resolve, reject) => entry.file(resolve, reject))
const relativePath = parentPath ? `${parentPath}/${entry.name}` : null
items.push({ file, relativePath })
} else if (entry.isDirectory) {
const dirPath = parentPath ? `${parentPath}/${entry.name}` : entry.name
const reader = entry.createReader()
// readEntries may need to be called multiple times
let allEntries = []
const readBatch = () => new Promise((resolve, reject) => reader.readEntries(resolve, reject))
let batch
do {
batch = await readBatch()
allEntries = allEntries.concat(batch)
} while (batch.length > 0)
for (const child of allEntries) {
await readEntry(child, dirPath, items)
}
}
}
// --- Upload with progress + folder creation ---
async function uploadFilesWithProgress(items) {
uploadTotal.value = items.length
uploadCurrent.value = 0
uploadPercent.value = 0
uploadQueue.value = items
// Group by directory path -> create folders first
const pathCache = {} // "dir/subdir" -> folder_id
let errors = 0
for (const item of items) {
try {
let targetParentId = currentParentId()
if (item.relativePath) {
// Extract directory part: "Folder/Sub/file.txt" -> "Folder/Sub"
const parts = item.relativePath.split('/')
parts.pop() // remove filename
const dirPath = parts.join('/')
if (dirPath && !pathCache[dirPath]) {
const res = await apiClient.post('/files/ensure-path', {
path: dirPath,
parent_id: currentParentId(),
})
pathCache[dirPath] = res.data.folder_id
// Also cache parent paths
for (let i = 1; i < parts.length; i++) {
const subPath = parts.slice(0, i).join('/')
if (!pathCache[subPath]) {
const subRes = await apiClient.post('/files/ensure-path', {
path: subPath,
parent_id: currentParentId(),
})
pathCache[subPath] = subRes.data.folder_id
}
}
}
if (dirPath) {
targetParentId = pathCache[dirPath]
}
}
await filesStore.uploadFile(item.file, targetParentId)
} catch (err) {
errors++
}
uploadCurrent.value++
uploadPercent.value = Math.round((uploadCurrent.value / uploadTotal.value) * 100)
}
uploadQueue.value = []
if (errors > 0) {
toast.add({ severity: 'warn', summary: `${uploadCurrent.value - errors} hochgeladen, ${errors} Fehler`, life: 5000 })
} else {
toast.add({ severity: 'success', summary: `${uploadCurrent.value} Dateien hochgeladen`, life: 3000 })
}
await filesStore.loadFiles(currentParentId())
} }
async function createFolder() { async function createFolder() {
@ -460,7 +635,23 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.view-container { padding: 1.5rem; } .view-container { padding: 1.5rem; position: relative; min-height: 400px; }
.drop-overlay {
position: absolute; inset: 0; z-index: 100;
background: rgba(var(--p-primary-500-rgb, 59, 130, 246), 0.08);
border: 3px dashed var(--p-primary-color);
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
pointer-events: none;
}
.drop-content { text-align: center; color: var(--p-primary-color); }
.drop-content i { font-size: 3rem; }
.drop-content p { font-size: 1.1rem; font-weight: 500; margin: 0.5rem 0 0; }
.upload-progress {
background: var(--p-surface-0); border-radius: 8px; padding: 0.75rem 1rem;
margin-bottom: 1rem; border: 1px solid var(--p-surface-200);
}
.upload-info { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.875rem; }
.view-header { .view-header {
display: flex; display: flex;
align-items: center; align-items: center;