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:
parent
10fde2396d
commit
61ce2ec244
|
|
@ -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'])
|
||||||
|
|
|
||||||
|
|
@ -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,21 +314,167 @@ 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) {
|
|
||||||
toast.add({ severity: 'error', summary: `Fehler: ${file.name}`, detail: err.response?.data?.error, life: 5000 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
if (!newFolderName.value.trim()) return
|
if (!newFolderName.value.trim()) return
|
||||||
try {
|
try {
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue