# 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.
