Datei-Upload: Mehrfach-Upload mit Drag & Drop und Dateiliste

- Upload-Formular unterstützt jetzt mehrere Dateien gleichzeitig
- Drag & Drop oder Klick zum Auswählen (mehrfach möglich)
- Dateiliste mit Dateiname, Größe, individueller Kategorie-Auswahl
  und Entfernen-Button pro Datei
- Standard-Kategorie kann oben gewählt werden, individuelle
  Kategorie pro Datei ist optional überschreibbar
- Controller verarbeitet Array von Dateien (je max. 10 MB)
- Übersetzungen in allen 6 Sprachen ergänzt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Rhino
2026-03-03 10:25:37 +01:00
parent 28beb99896
commit 5942bdf6c3
8 changed files with 174 additions and 45 deletions

View File

@@ -52,18 +52,33 @@ class FileController extends Controller
} }
$request->validate([ $request->validate([
'file' => ['required', 'file', 'max:10240', 'mimes:pdf,docx,xlsx,jpg,jpeg,png,gif,webp'], 'files' => ['required', 'array', 'min:1'],
'file_category_id' => ['required', 'exists:file_categories,id'], 'files.*' => ['file', 'max:10240', 'mimes:pdf,docx,xlsx,jpg,jpeg,png,gif,webp'],
'categories' => ['required', 'array', 'min:1'],
'categories.*' => ['required', 'exists:file_categories,id'],
]); ]);
$uploadedFile = $request->file('file'); $uploadedFiles = $request->file('files', []);
$categories = $request->input('categories', []);
$count = 0;
foreach ($uploadedFiles as $index => $uploadedFile) {
if (!$uploadedFile || !$uploadedFile->isValid()) {
continue;
}
$categoryId = $categories[$index] ?? null;
if (!$categoryId) {
continue;
}
$extension = $uploadedFile->guessExtension(); $extension = $uploadedFile->guessExtension();
$storedName = Str::uuid() . '.' . $extension; $storedName = Str::uuid() . '.' . $extension;
Storage::disk('local')->putFileAs('files', $uploadedFile, $storedName); Storage::disk('local')->putFileAs('files', $uploadedFile, $storedName);
$file = new File([ $file = new File([
'file_category_id' => $request->file_category_id, 'file_category_id' => $categoryId,
'original_name' => $uploadedFile->getClientOriginalName(), 'original_name' => $uploadedFile->getClientOriginalName(),
'mime_type' => $uploadedFile->getClientMimeType(), 'mime_type' => $uploadedFile->getClientMimeType(),
'size' => $uploadedFile->getSize(), 'size' => $uploadedFile->getSize(),
@@ -74,9 +89,15 @@ class FileController extends Controller
$file->save(); $file->save();
ActivityLog::logWithChanges('uploaded', __('admin.log_file_uploaded', ['name' => $file->original_name]), 'File', $file->id, null, ['name' => $file->original_name, 'category' => $file->category->name ?? '']); ActivityLog::logWithChanges('uploaded', __('admin.log_file_uploaded', ['name' => $file->original_name]), 'File', $file->id, null, ['name' => $file->original_name, 'category' => $file->category->name ?? '']);
$count++;
}
$message = $count === 1
? __('admin.file_uploaded')
: __('admin.files_uploaded', ['count' => $count]);
return redirect()->route('admin.files.index') return redirect()->route('admin.files.index')
->with('success', __('admin.file_uploaded')); ->with('success', $message);
} }
public function destroy(File $file): RedirectResponse public function destroy(File $file): RedirectResponse

View File

@@ -194,6 +194,12 @@ return [
'files_title' => 'إدارة الملفات', 'files_title' => 'إدارة الملفات',
'upload_file' => 'رفع ملف', 'upload_file' => 'رفع ملف',
'file_uploaded' => 'تم رفع الملف.', 'file_uploaded' => 'تم رفع الملف.',
'upload_files' => 'رفع الملفات',
'files_uploaded' => 'تم رفع :count ملفات.',
'select_files' => 'اختيار الملفات',
'drag_or_click_files' => 'اسحب الملفات هنا أو انقر للاختيار',
'files_selected' => 'ملف(ات) محددة',
'select_category_for_all' => 'يرجى اختيار فئة لجميع الملفات.',
'file_deleted' => 'تم حذف الملف.', 'file_deleted' => 'تم حذف الملف.',
'file_category' => 'الفئة', 'file_category' => 'الفئة',
'select_category' => 'اختر الفئة', 'select_category' => 'اختر الفئة',

View File

@@ -225,6 +225,12 @@ return [
'files_title' => 'Dateiverwaltung', 'files_title' => 'Dateiverwaltung',
'upload_file' => 'Datei hochladen', 'upload_file' => 'Datei hochladen',
'file_uploaded' => 'Datei wurde hochgeladen.', 'file_uploaded' => 'Datei wurde hochgeladen.',
'upload_files' => 'Dateien hochladen',
'files_uploaded' => ':count Dateien wurden hochgeladen.',
'select_files' => 'Dateien auswählen',
'drag_or_click_files' => 'Dateien hierher ziehen oder klicken zum Auswählen',
'files_selected' => 'Datei(en) ausgewählt',
'select_category_for_all' => 'Bitte wählen Sie für alle Dateien eine Kategorie.',
'file_deleted' => 'Datei wurde gelöscht.', 'file_deleted' => 'Datei wurde gelöscht.',
'file_category' => 'Kategorie', 'file_category' => 'Kategorie',
'select_category' => 'Kategorie wählen', 'select_category' => 'Kategorie wählen',

View File

@@ -193,6 +193,12 @@ return [
'files_title' => 'File Management', 'files_title' => 'File Management',
'upload_file' => 'Upload File', 'upload_file' => 'Upload File',
'file_uploaded' => 'File has been uploaded.', 'file_uploaded' => 'File has been uploaded.',
'upload_files' => 'Upload Files',
'files_uploaded' => ':count files have been uploaded.',
'select_files' => 'Select Files',
'drag_or_click_files' => 'Drag files here or click to select',
'files_selected' => 'file(s) selected',
'select_category_for_all' => 'Please select a category for all files.',
'file_deleted' => 'File has been deleted.', 'file_deleted' => 'File has been deleted.',
'file_category' => 'Category', 'file_category' => 'Category',
'select_category' => 'Select category', 'select_category' => 'Select category',

View File

@@ -194,6 +194,12 @@ return [
'files_title' => 'Zarządzanie plikami', 'files_title' => 'Zarządzanie plikami',
'upload_file' => 'Prześlij plik', 'upload_file' => 'Prześlij plik',
'file_uploaded' => 'Plik został przesłany.', 'file_uploaded' => 'Plik został przesłany.',
'upload_files' => 'Prześlij pliki',
'files_uploaded' => 'Przesłano :count plików.',
'select_files' => 'Wybierz pliki',
'drag_or_click_files' => 'Przeciągnij pliki tutaj lub kliknij, aby wybrać',
'files_selected' => 'plik(ów) wybranych',
'select_category_for_all' => 'Proszę wybrać kategorię dla wszystkich plików.',
'file_deleted' => 'Plik został usunięty.', 'file_deleted' => 'Plik został usunięty.',
'file_category' => 'Kategoria', 'file_category' => 'Kategoria',
'select_category' => 'Wybierz kategorię', 'select_category' => 'Wybierz kategorię',

View File

@@ -212,6 +212,12 @@ return [
'files_title' => 'Управление файлами', 'files_title' => 'Управление файлами',
'upload_file' => 'Загрузить файл', 'upload_file' => 'Загрузить файл',
'file_uploaded' => 'Файл был загружен.', 'file_uploaded' => 'Файл был загружен.',
'upload_files' => 'Загрузить файлы',
'files_uploaded' => 'Загружено файлов: :count.',
'select_files' => 'Выбрать файлы',
'drag_or_click_files' => 'Перетащите файлы сюда или нажмите для выбора',
'files_selected' => 'файл(ов) выбрано',
'select_category_for_all' => 'Пожалуйста, выберите категорию для всех файлов.',
'file_deleted' => 'Файл был удалён.', 'file_deleted' => 'Файл был удалён.',
'file_category' => 'Категория', 'file_category' => 'Категория',
'select_category' => 'Выберите категорию', 'select_category' => 'Выберите категорию',

View File

@@ -212,6 +212,12 @@ return [
'files_title' => 'Dosya Yönetimi', 'files_title' => 'Dosya Yönetimi',
'upload_file' => 'Dosya Yükle', 'upload_file' => 'Dosya Yükle',
'file_uploaded' => 'Dosya yüklendi.', 'file_uploaded' => 'Dosya yüklendi.',
'upload_files' => 'Dosyaları Yükle',
'files_uploaded' => ':count dosya yüklendi.',
'select_files' => 'Dosyaları Seç',
'drag_or_click_files' => 'Dosyaları buraya sürükleyin veya seçmek için tıklayın',
'files_selected' => 'dosya seçildi',
'select_category_for_all' => 'Lütfen tüm dosyalar için bir kategori seçin.',
'file_deleted' => 'Dosya silindi.', 'file_deleted' => 'Dosya silindi.',
'file_category' => 'Kategori', 'file_category' => 'Kategori',
'select_category' => 'Kategori seçin', 'select_category' => 'Kategori seçin',

View File

@@ -1,47 +1,119 @@
<x-layouts.admin :title="__('admin.upload_file')"> <x-layouts.admin :title="__('admin.upload_file')">
<h1 class="text-2xl font-bold mb-6">{{ __('admin.upload_file') }}</h1> <h1 class="text-2xl font-bold mb-6">{{ __('admin.upload_file') }}</h1>
<div class="bg-white rounded-lg shadow p-6 max-w-lg"> <div class="bg-white rounded-lg shadow p-6 max-w-2xl">
<form method="POST" action="{{ route('admin.files.store') }}" enctype="multipart/form-data"> <form method="POST" action="{{ route('admin.files.store') }}" enctype="multipart/form-data"
x-data="{
files: [],
dragging: false,
defaultCategory: '',
addFiles(fileList) {
for (const f of fileList) {
if (f.size > 10485760) continue;
this.files.push({ file: f, name: f.name, size: f.size, category: this.defaultCategory });
}
},
removeFile(index) {
this.files.splice(index, 1);
},
humanSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
}">
@csrf @csrf
{{-- Standard-Kategorie --}}
<div class="mb-4"> <div class="mb-4">
<label for="file_category_id" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.file_category') }} *</label> <label for="default_category" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.file_category') }} *</label>
<select name="file_category_id" id="file_category_id" required class="w-full px-3 py-2 border border-gray-300 rounded-md"> <select id="default_category" x-model="defaultCategory" class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">{{ __('admin.select_category') }}</option> <option value="">{{ __('admin.select_category') }}</option>
@foreach ($categories as $cat) @foreach ($categories as $cat)
<option value="{{ $cat->id }}" {{ old('file_category_id') == $cat->id ? 'selected' : '' }}>{{ $cat->name }}</option> <option value="{{ $cat->id }}">{{ $cat->name }}</option>
@endforeach @endforeach
</select> </select>
@error('file_category_id')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div> </div>
{{-- Drag & Drop Zone --}}
<div class="mb-4"> <div class="mb-4">
<label for="file" class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.upload_file') }} *</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('admin.select_files') }}</label>
<div x-data="{ fileName: '', dragging: false }" class="relative">
<div <div
@dragover.prevent="dragging = true" @dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false" @dragleave.prevent="dragging = false"
@drop.prevent="dragging = false; $refs.fileInput.files = $event.dataTransfer.files; fileName = $refs.fileInput.files[0]?.name || ''" @drop.prevent="dragging = false; addFiles($event.dataTransfer.files)"
:class="dragging ? 'border-blue-400 bg-blue-50' : 'border-gray-300'" :class="dragging ? 'border-blue-400 bg-blue-50' : 'border-gray-300'"
class="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 transition-colors" class="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 transition-colors"
@click="$refs.fileInput.click()"> @click="$refs.fileInput.click()">
<svg class="mx-auto h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="mx-auto h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg> </svg>
<p class="mt-2 text-sm text-gray-600" x-show="!fileName">{{ __('admin.allowed_file_types') }}</p> <p class="mt-2 text-sm text-gray-600" x-show="!files.length">{{ __('admin.drag_or_click_files') }}</p>
<p class="mt-1 text-xs text-gray-500" x-show="!fileName">{{ __('admin.max_file_size') }}</p> <p class="mt-1 text-xs text-gray-500" x-show="!files.length">{{ __('admin.allowed_file_types') }} · {{ __('admin.max_file_size') }}</p>
<p class="mt-2 text-sm font-medium text-blue-600" x-show="fileName" x-text="fileName"></p> <p class="mt-2 text-sm font-medium text-blue-600" x-show="files.length" x-text="files.length + ' {{ __('admin.files_selected') }}'"></p>
</div> </div>
<input type="file" name="file" id="file" x-ref="fileInput" class="hidden" <input type="file" x-ref="fileInput" class="hidden" multiple
accept=".pdf,.docx,.xlsx,.jpg,.jpeg,.png,.gif,.webp" accept=".pdf,.docx,.xlsx,.jpg,.jpeg,.png,.gif,.webp"
@change="fileName = $refs.fileInput.files[0]?.name || ''"> @change="addFiles($refs.fileInput.files); $refs.fileInput.value = ''">
</div> @error('files')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
@error('file')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror @error('files.*')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
@error('categories')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
@error('categories.*')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
</div> </div>
{{-- Datei-Liste --}}
<template x-if="files.length > 0">
<div class="mb-4 space-y-2">
<template x-for="(f, index) in files" :key="index">
<div class="flex items-center gap-3 bg-gray-50 rounded-md px-3 py-2">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate" x-text="f.name"></p>
<p class="text-xs text-gray-500" x-text="humanSize(f.size)"></p>
</div>
<select x-model="f.category" class="px-2 py-1 border border-gray-300 rounded-md text-sm shrink-0">
<option value="">{{ __('admin.select_category') }}</option>
@foreach ($categories as $cat)
<option value="{{ $cat->id }}">{{ $cat->name }}</option>
@endforeach
</select>
<button type="button" @click="removeFile(index)" class="text-red-500 hover:text-red-700 shrink-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</template>
</div>
</template>
{{-- Hidden Inputs für tatsächlichen Upload --}}
<input type="file" name="files[]" x-ref="submitFiles" class="hidden" multiple>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium">{{ __('admin.upload_file') }}</button> <button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 text-sm font-medium"
:disabled="!files.length"
:class="!files.length ? 'opacity-50 cursor-not-allowed' : ''"
@click.prevent="
const dt = new DataTransfer();
const catValues = [];
let valid = true;
files.forEach(f => {
const cat = f.category || defaultCategory;
if (!cat) { valid = false; }
dt.items.add(f.file);
catValues.push(cat);
});
if (!valid) { alert('{{ __('admin.select_category_for_all') }}'); return; }
$refs.submitFiles.files = dt.files;
catValues.forEach((c, i) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'categories[' + i + ']';
input.value = c;
$el.closest('form').appendChild(input);
});
$el.closest('form').submit();
">
{{ __('admin.upload_files') }}
</button>
<a href="{{ route('admin.files.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a> <a href="{{ route('admin.files.index') }}" class="text-sm text-gray-600 hover:underline">{{ __('ui.cancel') }}</a>
</div> </div>
</form> </form>