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
|
||||
|
||||
|
||||
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 ---
|
||||
|
||||
@api_bp.route('/files/upload', methods=['POST'])
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
<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="breadcrumb">
|
||||
<a @click="navigateTo(null)" class="crumb">Dateien</a>
|
||||
|
|
@ -10,11 +21,22 @@
|
|||
</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" />
|
||||
<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="folderInput" type="file" hidden webkitdirectory @change="handleFolderUpload" />
|
||||
</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
|
||||
:value="filesStore.files"
|
||||
:loading="filesStore.loading"
|
||||
|
|
@ -198,6 +220,7 @@ import InputText from 'primevue/inputtext'
|
|||
import Password from 'primevue/password'
|
||||
import Tag from 'primevue/tag'
|
||||
import Select from 'primevue/select'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
|
@ -205,6 +228,12 @@ const filesStore = useFilesStore()
|
|||
const toast = useToast()
|
||||
|
||||
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 newFolderName = ref('')
|
||||
const showRename = ref(false)
|
||||
|
|
@ -285,19 +314,165 @@ function triggerUpload() {
|
|||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleUpload(event) {
|
||||
const uploadFiles = event.target.files
|
||||
if (!uploadFiles.length) return
|
||||
function triggerFolderUpload() {
|
||||
folderInput.value?.click()
|
||||
}
|
||||
|
||||
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 })
|
||||
async function handleUpload(event) {
|
||||
const files = Array.from(event.target.files)
|
||||
if (!files.length) return
|
||||
await uploadFilesWithProgress(files.map(f => ({ file: f, relativePath: null })))
|
||||
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 })
|
||||
}
|
||||
}
|
||||
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() {
|
||||
|
|
@ -460,7 +635,23 @@ onMounted(() => {
|
|||
</script>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
Loading…
Reference in New Issue