10-04-2026
This commit is contained in:
parent
4d6b4930b2
commit
4bb89aad8c
836 changed files with 52961 additions and 5950 deletions
458
dev/file-upload/README.md
Normal file
458
dev/file-upload/README.md
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
# File Upload mit Livewire Volt + Flux UI
|
||||
|
||||
Vollständige Referenz für den Bild-Upload, wie er in `resources/views/livewire/products/form-teaser.blade.php` implementiert ist.
|
||||
|
||||
---
|
||||
|
||||
## Überblick
|
||||
|
||||
Der Upload nutzt:
|
||||
- **Livewire `WithFileUploads`** – verwaltet temporäre Uploads via signierter URL
|
||||
- **Flux UI `flux:file-upload`** – UI-Komponente (Dropzone + Vorschau)
|
||||
- **Laravel `Storage::disk('public')`** – Permanente Speicherung
|
||||
- **Polymorphe `media`-Tabelle** – Zuordnung von Dateien zu beliebigen Models
|
||||
- **Alpine.js** – Drag-&-Drop-Sortierung der vorhandenen Bilder
|
||||
|
||||
---
|
||||
|
||||
## 1. PHP / Livewire Volt – Komponentenlogik
|
||||
|
||||
### Trait einbinden
|
||||
|
||||
```php
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
new class extends Component {
|
||||
use WithFileUploads;
|
||||
|
||||
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
|
||||
public array $mainImages = [];
|
||||
```
|
||||
|
||||
`WithFileUploads` muss zwingend eingebunden sein. Ohne ihn reagiert `wire:model` nicht auf Datei-Inputs.
|
||||
|
||||
### Validierung
|
||||
|
||||
```php
|
||||
'mainImages' => 'nullable|array|min:0|max:10',
|
||||
'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240',
|
||||
```
|
||||
|
||||
- `mainImages` ist ein **Array** (wegen `multiple`-Upload)
|
||||
- `mainImages.*` validiert jede einzelne Datei
|
||||
- `max:10240` = 10 MB in Kilobyte
|
||||
- Livewire hat intern ein Default-Limit von 12 MB – das greift, bevor die eigene Validierung läuft (Achtung: Wert muss ≤ 12288 KB sein oder `livewire.temporary_file_upload.rules` anpassen)
|
||||
|
||||
### Einzelnes Bild entfernen (Vorschauliste)
|
||||
|
||||
```php
|
||||
public function removePhoto(int $index): void
|
||||
{
|
||||
if (isset($this->mainImages[$index])) {
|
||||
unset($this->mainImages[$index]);
|
||||
$this->mainImages = array_values($this->mainImages);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Nach `unset()` unbedingt `array_values()` aufrufen, damit die Array-Indizes wieder bei 0 beginnen – sonst bricht `@foreach` mit `$index` im Template.
|
||||
|
||||
### Vorhandenes Bild aus der DB löschen
|
||||
|
||||
```php
|
||||
public function removeExistingMedia(int $mediaId): void
|
||||
{
|
||||
$media = $this->product->media()->find($mediaId);
|
||||
if ($media) {
|
||||
Storage::disk('public')->delete($media->file_path);
|
||||
$media->delete();
|
||||
$this->existingMedia = collect($this->existingMedia)
|
||||
->reject(fn ($m) => $m['id'] === $mediaId)
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Immer erst die **Datei** vom Disk löschen, dann den **DB-Eintrag**. Anschließend `$this->existingMedia` synchronisieren, damit Livewire den State neu rendert.
|
||||
|
||||
### Reihenfolge aktualisieren (Drag & Drop)
|
||||
|
||||
```php
|
||||
public function updateMediaOrder(array $orderedIds): void
|
||||
{
|
||||
foreach ($orderedIds as $position => $mediaId) {
|
||||
$this->product->media()
|
||||
->where('id', $mediaId)
|
||||
->update(['order_column' => $position + 1]);
|
||||
}
|
||||
|
||||
// Lokalen State synchronisieren
|
||||
$this->existingMedia = collect($orderedIds)
|
||||
->map(fn ($id, $index) => collect($this->existingMedia)->firstWhere('id', $id)
|
||||
? array_merge(collect($this->existingMedia)->firstWhere('id', $id), ['order_column' => $index + 1])
|
||||
: null
|
||||
)
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
```
|
||||
|
||||
### Bilder permanent speichern (Neu-Anlage)
|
||||
|
||||
```php
|
||||
$index = 1;
|
||||
foreach ($this->mainImages as $image) {
|
||||
$path = $image->store('products/' . $product->id, 'public');
|
||||
$product->media()->create([
|
||||
'file_path' => $path,
|
||||
'type' => 'image',
|
||||
'alt_text' => $this->name,
|
||||
'order_column' => $index++,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
`$image->store(...)` verschiebt die temporäre Livewire-Datei in den endgültigen Pfad auf dem `public`-Disk.
|
||||
|
||||
### Bilder permanent speichern (Bearbeiten – neue Bilder hinzufügen)
|
||||
|
||||
```php
|
||||
$maxOrder = $this->product->media()->max('order_column') ?? 0;
|
||||
$index = $maxOrder + 1;
|
||||
foreach ($this->mainImages as $image) {
|
||||
$path = $image->store('products/' . $this->product->id, 'public');
|
||||
$this->product->media()->create([
|
||||
'file_path' => $path,
|
||||
'type' => 'image',
|
||||
'alt_text' => $this->name,
|
||||
'order_column' => $index++,
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
`order_column` an bestehenden Max-Wert anhängen, nicht von 1 neu beginnen.
|
||||
|
||||
### Nach dem Speichern zurücksetzen
|
||||
|
||||
```php
|
||||
// Neue Bilder leeren
|
||||
$this->mainImages = [];
|
||||
|
||||
// Vorhandene Bilder aus DB neu laden (mit sortBy)
|
||||
$this->existingMedia = $this->product->fresh()->media
|
||||
->sortBy('order_column')
|
||||
->values()
|
||||
->map(fn ($m) => [
|
||||
'id' => $m->id,
|
||||
'file_path' => $m->file_path,
|
||||
'alt_text' => $m->alt_text,
|
||||
'order_column' => $m->order_column,
|
||||
])
|
||||
->toArray();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Blade / Flux UI – Template
|
||||
|
||||
### Upload-Dropzone
|
||||
|
||||
```blade
|
||||
<flux:file-upload wire:model="mainImages" label="Upload files" multiple
|
||||
accept="image/jpeg,image/png,.jpg,.jpeg,.png">
|
||||
<flux:file-upload.dropzone
|
||||
heading="Bilder hochladen"
|
||||
text="Nur JPEG oder PNG – max. 10 MB"
|
||||
with-progress />
|
||||
</flux:file-upload>
|
||||
```
|
||||
|
||||
- `wire:model="mainImages"` – bindet an das Array-Property
|
||||
- `multiple` – erlaubt Mehrfachauswahl
|
||||
- `accept` – schränkt den Browser-Dateidialog ein (kein serverseitiger Schutz!)
|
||||
- `with-progress` – zeigt Upload-Fortschrittsbalken
|
||||
|
||||
### Vorschauliste der neu hinzugefügten Bilder
|
||||
|
||||
```blade
|
||||
@if (isset($mainImages) && count($mainImages) > 0)
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
@foreach ($mainImages as $index => $image)
|
||||
<flux:file-item
|
||||
:heading="$image->getClientOriginalName()"
|
||||
:image="(str_starts_with($image->getMimeType() ?? '', 'image/') && $image->isPreviewable())
|
||||
? $image->temporaryUrl()
|
||||
: null"
|
||||
:size="$image->getSize()">
|
||||
<x-slot name="actions">
|
||||
<flux:file-item.remove
|
||||
wire:click="removePhoto({{ $index }})"
|
||||
aria-label="{{ 'Remove file: ' . $image->getClientOriginalName() }}" />
|
||||
</x-slot>
|
||||
</flux:file-item>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
```
|
||||
|
||||
**Wichtig bei `temporaryUrl()`:**
|
||||
Die Methode gibt nur dann eine URL zurück, wenn die Datei auch vorschaubar ist (`isPreviewable()` prüft die MIME-Type-Whitelist in `config/livewire.php`). Immer beide Bedingungen prüfen, sonst Fehler.
|
||||
|
||||
### Fehleranzeige
|
||||
|
||||
```blade
|
||||
<flux:error name="mainImages" />
|
||||
```
|
||||
|
||||
Zeigt Validierungsfehler für das gesamte Array an (z. B. „Maximal 10 Bilder erlaubt.").
|
||||
Für Fehler auf einzelnen Dateien würde `name="mainImages.0"` etc. verwendet.
|
||||
|
||||
### Drag-&-Drop-Sortierung vorhandener Bilder
|
||||
|
||||
```blade
|
||||
<div x-data="{
|
||||
dragging: null,
|
||||
dragOver: null,
|
||||
items: @js(collect($existingMedia)->pluck('id')->toArray()),
|
||||
onDragStart(e, id) {
|
||||
this.dragging = id;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', id);
|
||||
},
|
||||
onDragOver(e, id) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
this.dragOver = id;
|
||||
},
|
||||
onDrop(e, targetId) {
|
||||
e.preventDefault();
|
||||
if (this.dragging === targetId) { this.dragOver = null; return; }
|
||||
const fromIdx = this.items.indexOf(this.dragging);
|
||||
const toIdx = this.items.indexOf(targetId);
|
||||
this.items.splice(fromIdx, 1);
|
||||
this.items.splice(toIdx, 0, this.dragging);
|
||||
this.dragging = null;
|
||||
this.dragOver = null;
|
||||
$wire.updateMediaOrder(this.items);
|
||||
},
|
||||
onDragEnd() {
|
||||
this.dragging = null;
|
||||
this.dragOver = null;
|
||||
}
|
||||
}" class="flex flex-wrap items-start gap-3">
|
||||
|
||||
@foreach ($existingMedia as $mediaIndex => $media)
|
||||
<div wire:key="existing-media-{{ $media['id'] }}"
|
||||
draggable="true"
|
||||
x-on:dragstart="onDragStart($event, {{ $media['id'] }})"
|
||||
x-on:dragover="onDragOver($event, {{ $media['id'] }})"
|
||||
x-on:drop="onDrop($event, {{ $media['id'] }})"
|
||||
x-on:dragend="onDragEnd()"
|
||||
:class="{
|
||||
'opacity-50 scale-95': dragging === {{ $media['id'] }},
|
||||
'ring-2 ring-blue-400 ring-offset-2': dragOver === {{ $media['id'] }} && dragging !== {{ $media['id'] }}
|
||||
}"
|
||||
class="group relative cursor-grab active:cursor-grabbing transition-all duration-150">
|
||||
|
||||
@if ($mediaIndex === 0)
|
||||
<div class="absolute -top-1 -left-1 z-10 bg-blue-600 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-md shadow">
|
||||
Standard
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<img src="{{ Storage::url($media['file_path']) }}"
|
||||
alt="{{ $media['alt_text'] ?? '' }}"
|
||||
class="h-24 w-24 rounded-lg object-cover border border-zinc-200 dark:border-zinc-700
|
||||
{{ $mediaIndex === 0 ? 'ring-2 ring-blue-500' : '' }}" />
|
||||
|
||||
<flux:button
|
||||
wire:click="removeExistingMedia({{ $media['id'] }})"
|
||||
wire:confirm="Bild wirklich löschen?"
|
||||
variant="filled" size="xs" icon="trash"
|
||||
class="absolute -top-2 -right-2 !bg-red-500 !text-white hover:!bg-red-600" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
```
|
||||
|
||||
**Wichtig:** `wire:key="existing-media-{{ $media['id'] }}"` ist zwingend, damit Livewire beim Re-Render die DOM-Elemente korrekt zuordnet.
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenbank – Media-Tabelle
|
||||
|
||||
```php
|
||||
// Migration: database/migrations/xxxx_create_media_table.php
|
||||
|
||||
Schema::create('media', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('model_type'); // z. B. "App\Models\Product"
|
||||
$table->unsignedBigInteger('model_id');
|
||||
$table->string('file_path'); // relativer Pfad auf dem public-Disk
|
||||
$table->string('type')->default('image'); // 'image', 'video', 'pdf', '3d_model'
|
||||
$table->string('alt_text')->nullable();
|
||||
$table->integer('order_column')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['model_type', 'model_id']);
|
||||
});
|
||||
```
|
||||
|
||||
### Media-Model (`app/Models/Media.php`)
|
||||
|
||||
```php
|
||||
class Media extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'model_type', 'model_id', 'file_path', 'type', 'alt_text', 'order_column',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['order_column' => 'integer'];
|
||||
}
|
||||
|
||||
/** Polymorphe Beziehung zum Eltern-Model */
|
||||
public function model(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Beziehung im Parent-Model (`app/Models/Product.php`)
|
||||
|
||||
```php
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
public function media(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Media::class, 'model');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Filesystem-Konfiguration (`config/filesystems.php`)
|
||||
|
||||
```php
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => env('APP_URL') . '/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
],
|
||||
```
|
||||
|
||||
### Symlink anlegen
|
||||
|
||||
```bash
|
||||
php artisan storage:link
|
||||
```
|
||||
|
||||
Erstellt `public/storage` → `storage/app/public`. **Ohne diesen Symlink sind hochgeladene Bilder nicht über den Browser erreichbar.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Kritische System-Anpassungen
|
||||
|
||||
### 5a. `bootstrap/app.php` – Reverse-Proxy / HTTPS
|
||||
|
||||
```php
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
// Traefik/nginx Proxy: X-Forwarded-Proto-Header vertrauen
|
||||
// ZWINGEND für korrekte signierte Upload-URLs hinter einem HTTPS-Proxy
|
||||
$middleware->trustProxies(at: '*');
|
||||
})
|
||||
```
|
||||
|
||||
**Warum?**
|
||||
Livewire generiert für temporäre Uploads **signierte URLs**. Wenn die App hinter einem Reverse-Proxy (Traefik, nginx, Load Balancer) läuft und der Proxy HTTPS terminiert, glaubt Laravel intern, es sei HTTP. Die signierte URL wird dann mit `http://` generiert, der Browser sendet aber `https://` – die Signatur stimmt nicht, Upload schlägt fehl mit `403`.
|
||||
|
||||
### 5b. `app/Providers/AppServiceProvider.php` – Schema erzwingen
|
||||
|
||||
```php
|
||||
public function boot(): void
|
||||
{
|
||||
// X-Forwarded-Proto auswerten und Schema erzwingen
|
||||
// Nötig für Livewire Upload-URLs hinter Traefik
|
||||
$scheme = request()->header('X-Forwarded-Proto')
|
||||
?? request()->server('HTTP_X_FORWARDED_PROTO')
|
||||
?? (request()->secure() ? 'https' : 'http');
|
||||
|
||||
if ($scheme === 'https') {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Warum zusätzlich zum `trustProxies`?**
|
||||
`trustProxies` reicht in manchen Proxy-Setups nicht aus, wenn der Header-Name variiert. `URL::forceScheme('https')` ist die sichere Ergänzung, die sicherstellt, dass alle generierten URLs das korrekte Schema haben.
|
||||
|
||||
**Ohne diese beiden Maßnahmen** scheitert der Upload mit einer `403 Signature mismatch`-Fehlermeldung in der Browser-Console – besonders frustrierend, weil kein PHP-Fehler erscheint.
|
||||
|
||||
---
|
||||
|
||||
## 6. Livewire-Konfiguration (`config/livewire.php`)
|
||||
|
||||
```php
|
||||
'temporary_file_upload' => [
|
||||
'disk' => null, // null = default-Disk (meist 'local')
|
||||
'rules' => null, // null = ['required', 'file', 'max:12288'] (12 MB Default)
|
||||
'directory' => null, // null = 'livewire-tmp'
|
||||
'middleware' => null, // null = 'throttle:60,1'
|
||||
'preview_mimes' => [ // Diese MIME-Types erlauben temporaryUrl()
|
||||
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp',
|
||||
'pdf', 'mp4', 'mov', 'avi', 'wmv', 'mp3', ...
|
||||
],
|
||||
'max_upload_time' => 5, // Minuten bis Upload ungültig wird
|
||||
'cleanup' => true, // Tmp-Dateien > 24h automatisch löschen
|
||||
],
|
||||
```
|
||||
|
||||
**Wichtig:** Das interne Default-Limit ist **12 MB** (`max:12288`). Eigene Validierungsregeln wie `max:10240` müssen immer unter diesem Wert liegen. Soll das Limit erhöht werden, muss `rules` hier überschrieben werden.
|
||||
|
||||
---
|
||||
|
||||
## 7. Checkliste für ein neues Projekt
|
||||
|
||||
| Schritt | Was | Wo |
|
||||
|---------|-----|----|
|
||||
| ✅ | `use WithFileUploads` im Volt/Livewire-Component | Komponentenklasse |
|
||||
| ✅ | `public array $images = []` Property anlegen | Komponentenklasse |
|
||||
| ✅ | `'images.*' => 'mimes:jpeg,png\|max:10240'` Validierung | `save()`-Methode |
|
||||
| ✅ | `$image->store('pfad', 'public')` beim Speichern | `save()`-Methode |
|
||||
| ✅ | `$this->images = []` nach dem Speichern leeren | `save()`-Methode |
|
||||
| ✅ | `php artisan storage:link` ausführen | Terminal / Deploy |
|
||||
| ✅ | `$middleware->trustProxies(at: '*')` | `bootstrap/app.php` |
|
||||
| ✅ | `URL::forceScheme('https')` bei HTTPS-Proxy | `AppServiceProvider.php` |
|
||||
| ✅ | `wire:key` in Foreach-Schleifen | Blade-Template |
|
||||
| ✅ | `array_values()` nach `unset()` auf dem Array | `removePhoto()` |
|
||||
| ✅ | `isPreviewable()` vor `temporaryUrl()` prüfen | Blade-Template |
|
||||
|
||||
---
|
||||
|
||||
## 8. Häufige Fallstricke
|
||||
|
||||
### Upload schlägt fehl mit 403 (Signature mismatch)
|
||||
→ Reverse-Proxy-Problem. Siehe Punkt 5a und 5b.
|
||||
|
||||
### Vorschau-Thumbnail zeigt nichts an
|
||||
→ `isPreviewable()` gibt `false` zurück, wenn der MIME-Type nicht in `preview_mimes` steht. In der Livewire-Config prüfen.
|
||||
|
||||
### Nach `removePhoto()` stimmen die Indizes nicht
|
||||
→ `array_values()` vergessen. Livewire sendet den Index als Parameter – ohne Reindizierung kommt es zu Off-by-One-Fehlern.
|
||||
|
||||
### Upload-Limit-Fehler vor der Validierung
|
||||
→ PHP `upload_max_filesize` und `post_max_size` in `php.ini` überprüfen. Auch Livewires internes `max:12288`-Limit beachten.
|
||||
|
||||
### `temporaryUrl()` wirft eine Exception
|
||||
→ Bei lokalen Disks ohne `serve: true` in `filesystems.php` funktioniert `temporaryUrl()` nicht. Entweder `serve: true` setzen oder S3 verwenden. Im Template immer mit `isPreviewable()` absichern.
|
||||
|
||||
### Bilder nach Deploy nicht sichtbar
|
||||
→ `php artisan storage:link` auf dem Produktionssystem ausführen. Im Docker-Container nach jedem `down/up` prüfen, ob der Symlink noch existiert.
|
||||
Loading…
Add table
Add a link
Reference in a new issue