b2in/resources/views/livewire/admin/cms/display-media-library.blade.php
Kevin Adametz 6c6d683b9a Display CMS Optimierungen 29-05-2026
- Mediathek: Video-Vorschaubilder statt Icons (FFmpeg-Thumbnails + Backfill-Command), Kategorie "Sonstiges"
- B2in Media-Picker zeigt alle Medientypen, Typ wird automatisch erkannt; Thumbnail-Preview vor allen Medien-URL-Feldern
- B2in Marke/Footer: Footer ein/aus, Logo+Claim frei positionierbar (Ecken) mit Constraints, separate Anzeige-Schalter
- Angebote-Modul dynamisch: kein Slide-Typ mehr, einheitliches Detail-Layout mit ein-/ausblendbaren Bloecken, Logo/Brand pro Slide, Streichpreis-Option
- Player: leere Module stoppen Endlosschleife, dynamische Layout-Anpassung bei verstecktem Footer/Header
- Fix: Script-Ladereihenfolge (Livewire vor Flux), entfernte stale public/flux/flux.js, Modal-Crash beim Aktualisieren behoben

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:57:33 +00:00

565 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Models\DisplayMedia;
use App\Services\DisplayMediaService;
use Flux\Flux;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Livewire\WithPagination;
use function Livewire\Volt\{layout, title, state, computed, on, uses};
layout('components.layouts.app');
title('Display-Mediathek');
uses([WithFileUploads::class, WithPagination::class]);
state([
'search' => '',
'filterType' => 'all',
'filterSource' => 'all',
'filterCollection' => '',
'viewMode' => 'grid',
'editingId' => null,
'editTitle' => '',
'editAltText' => '',
'editCollection' => '',
'showDetail' => false,
// Upload
'uploads' => [],
// External URL form
'showUrlModal' => false,
'urlInput' => '',
'urlType' => 'video',
'urlTitle' => '',
'urlCollection' => '',
'urlValidated' => null,
]);
$media = computed(
fn () => DisplayMedia::query()
->when($this->filterType !== 'all', fn ($q) => match ($this->filterType) {
'image' => $q->images(),
'video' => $q->videos(),
default => $q,
})
->when($this->filterSource !== 'all', fn ($q) => match ($this->filterSource) {
'upload' => $q->uploads(),
'external' => $q->externals(),
default => $q,
})
->when($this->filterCollection, fn ($q) => $q->inCollection($this->filterCollection))
->when($this->search, fn ($q) => $q->search($this->search))
->orderByDesc('created_at')
->paginate(48),
);
$updatedSearch = fn () => $this->resetPage();
$updatedFilterType = fn () => $this->resetPage();
$updatedFilterSource = fn () => $this->resetPage();
$updatedFilterCollection = fn () => $this->resetPage();
$collections = computed(fn () => DisplayMedia::query()
->whereNotNull('collection')
->where('collection', '!=', '')
->distinct()
->pluck('collection')
->sort()
->values()
->toArray());
$stats = computed(fn () => [
'total' => DisplayMedia::count(),
'images' => DisplayMedia::images()->count(),
'videos' => DisplayMedia::videos()->count(),
'uploads' => DisplayMedia::uploads()->count(),
'externals' => DisplayMedia::externals()->count(),
]);
// ========================================
// FILE UPLOAD
// ========================================
$handleUploads = function () {
$this->validate([
'uploads' => 'nullable|array|max:10',
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,mp4,webm,mov|max:204800',
]);
$service = app(DisplayMediaService::class);
$count = 0;
foreach ($this->uploads as $file) {
$service->storeUpload($file);
$count++;
}
$this->uploads = [];
if ($count > 0) {
Flux::toast(variant: 'success', heading: 'Hochgeladen', text: "{$count} Datei(en) erfolgreich hochgeladen.");
}
};
// ========================================
// EXTERNAL URL
// ========================================
$openUrlModal = function () {
$this->urlInput = '';
$this->urlType = 'video';
$this->urlTitle = '';
$this->urlCollection = '';
$this->urlValidated = null;
$this->showUrlModal = true;
};
$validateUrl = function () {
$this->validate(['urlInput' => 'required|url|max:2048']);
$service = app(DisplayMediaService::class);
$this->urlValidated = $service->validateExternalUrl($this->urlInput);
};
$saveExternalUrl = function () {
$this->validate([
'urlInput' => 'required|url|max:2048',
'urlType' => 'required|in:image,video',
'urlTitle' => 'nullable|string|max:255',
]);
$service = app(DisplayMediaService::class);
$service->createFromUrl(
url: $this->urlInput,
type: $this->urlType,
title: $this->urlTitle ?: null,
collection: $this->urlCollection ?: null,
);
$this->showUrlModal = false;
Flux::toast(variant: 'success', heading: 'Externe URL angelegt', text: 'Das Medium wurde als externe Referenz gespeichert.');
};
// ========================================
// DETAIL / EDIT
// ========================================
$startEdit = function (int $id) {
$media = DisplayMedia::find($id);
if (! $media) {
return;
}
$this->editingId = $id;
$this->editTitle = $media->title ?? '';
$this->editAltText = $media->alt_text ?? '';
$this->editCollection = $media->collection ?? '';
$this->showDetail = true;
};
$saveEdit = function () {
$media = DisplayMedia::find($this->editingId);
if (! $media) {
return;
}
$media->update([
'title' => $this->editTitle ?: null,
'alt_text' => $this->editAltText ?: null,
'collection' => $this->editCollection ?: null,
]);
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Medien-Details wurden aktualisiert.');
};
$deleteMedia = function (int $id) {
$media = DisplayMedia::find($id);
if (! $media) {
return;
}
$filename = $media->getDisplayName();
$service = app(DisplayMediaService::class);
$service->delete($media);
$this->editingId = null;
$this->showDetail = false;
Flux::toast(variant: 'success', heading: 'Gelöscht', text: $filename . ' wurde entfernt.');
};
$closeDetail = function () {
$this->showDetail = false;
$this->editingId = null;
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<div>
<flux:heading size="xl">Display-Mediathek</flux:heading>
<flux:text class="mt-1">Bilder, Videos und externe URLs für Store Displays verwalten.</flux:text>
</div>
<div class="flex items-center gap-3">
<flux:badge color="sky">{{ $this->stats['images'] }} Bilder</flux:badge>
<flux:badge color="purple">{{ $this->stats['videos'] }} Videos</flux:badge>
<flux:badge color="blue">{{ $this->stats['externals'] }} Extern</flux:badge>
</div>
</div>
{{-- Upload + External URL --}}
<flux:card class="mb-6">
<div class="flex items-start gap-4">
<div class="flex-1">
<flux:file-upload wire:model="uploads" multiple
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,video/mp4,video/webm,.jpg,.jpeg,.png,.gif,.webp,.svg,.mp4,.webm,.mov">
<flux:file-upload.dropzone
heading="Dateien hochladen"
text="Bilder inkl. SVG & Videos bis 200 MB - Drag & Drop oder klicken"
with-progress />
</flux:file-upload>
@if (isset($uploads) && count($uploads) > 0)
<div class="mt-3 flex flex-wrap items-center gap-2">
@foreach ($uploads as $index => $upload)
<flux:file-item
:heading="$upload->getClientOriginalName()"
:image="(str_starts_with($upload->getMimeType() ?? '', 'image/') && $upload->isPreviewable())
? $upload->temporaryUrl()
: null"
:size="$upload->getSize()" />
@endforeach
</div>
<flux:button wire:click="handleUploads" variant="primary" size="sm" class="mt-3">
{{ count($uploads) }} Datei(en) hochladen
</flux:button>
@endif
<flux:error name="uploads" />
</div>
<div class="flex flex-col gap-2 pt-2">
<flux:button wire:click="openUrlModal" icon="link" variant="ghost">
Externe URL anlegen
</flux:button>
</div>
</div>
</flux:card>
{{-- Filters --}}
<div class="mb-4 flex flex-wrap items-center gap-3">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." icon="magnifying-glass" size="sm" class="w-56" />
<flux:select wire:model.live="filterType" size="sm" class="w-36">
<flux:select.option value="all">Alle Typen</flux:select.option>
<flux:select.option value="image">Bilder</flux:select.option>
<flux:select.option value="video">Videos</flux:select.option>
</flux:select>
<flux:select wire:model.live="filterSource" size="sm" class="w-36">
<flux:select.option value="all">Alle Quellen</flux:select.option>
<flux:select.option value="upload">Uploads</flux:select.option>
<flux:select.option value="external">Extern</flux:select.option>
</flux:select>
@if (! empty($this->collections))
<flux:select wire:model.live="filterCollection" size="sm" class="w-40">
<flux:select.option value="">Alle Sammlungen</flux:select.option>
@foreach ($this->collections as $col)
<flux:select.option value="{{ $col }}">{{ $col }}</flux:select.option>
@endforeach
</flux:select>
@endif
<div class="ml-auto flex items-center gap-1 rounded-lg border border-zinc-200 p-0.5 dark:border-zinc-700">
<button wire:click="$set('viewMode', 'grid')"
class="rounded-md p-1.5 transition {{ $viewMode === 'grid' ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-600 dark:text-white' : 'text-zinc-400 hover:text-zinc-600' }}">
<x-heroicon-s-squares-2x2 class="h-4 w-4" />
</button>
<button wire:click="$set('viewMode', 'list')"
class="rounded-md p-1.5 transition {{ $viewMode === 'list' ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-600 dark:text-white' : 'text-zinc-400 hover:text-zinc-600' }}">
<x-heroicon-s-list-bullet class="h-4 w-4" />
</button>
</div>
</div>
{{-- Media Grid / List + Detail --}}
<div class="grid grid-cols-1 gap-6 {{ $showDetail ? 'lg:grid-cols-3' : '' }}">
<div class="{{ $showDetail ? 'lg:col-span-2' : '' }}">
@if ($viewMode === 'grid')
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 {{ $showDetail ? 'lg:grid-cols-3' : 'lg:grid-cols-6' }}">
@forelse ($this->media as $item)
<div wire:key="dm-g-{{ $item->id }}"
class="group relative cursor-pointer overflow-hidden rounded-lg border transition-all
{{ $editingId === $item->id ? 'border-blue-500 ring-2 ring-blue-200 dark:ring-blue-800' : 'border-zinc-200 hover:border-zinc-400 dark:border-zinc-700' }}"
wire:click="startEdit({{ $item->id }})">
@php
$thumbSrc = $item->getThumbnailUrl() ?? ($item->isImage() && $item->isExternal() ? $item->external_url : null);
$videoFrameSrc = (! $thumbSrc && $item->isVideo() && $item->isUpload()) ? $item->getUrl() : null;
@endphp
<div class="relative aspect-square bg-zinc-100 dark:bg-zinc-800">
@if ($thumbSrc)
<img src="{{ $thumbSrc }}"
alt="{{ $item->alt_text ?? $item->filename }}"
class="h-full w-full object-cover" loading="lazy" />
@elseif ($videoFrameSrc)
<video class="h-full w-full object-cover" preload="metadata" muted playsinline>
<source src="{{ $videoFrameSrc }}#t=1" type="{{ $item->mime_type }}">
</video>
@elseif ($item->isVideo())
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-purple-400">
<x-heroicon-o-film class="h-10 w-10" />
<span class="text-xs">Video</span>
</div>
@else
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-zinc-400">
<x-heroicon-o-link class="h-10 w-10" />
<span class="text-xs">Link</span>
</div>
@endif
@if ($item->isVideo() && ($thumbSrc || $videoFrameSrc))
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span class="flex h-10 w-10 items-center justify-center rounded-full bg-black/45 text-white ring-1 ring-white/30 backdrop-blur-sm">
<x-heroicon-s-play class="h-5 w-5" />
</span>
</div>
@endif
</div>
<div class="flex items-center gap-1.5 p-2">
@if ($item->isVideo())
<x-heroicon-s-film class="h-3.5 w-3.5 shrink-0 text-purple-500" />
@elseif ($item->isExternal())
<x-heroicon-s-link class="h-3.5 w-3.5 shrink-0 text-blue-500" />
@else
<x-heroicon-s-photo class="h-3.5 w-3.5 shrink-0 text-sky-500" />
@endif
<div class="min-w-0">
<p class="truncate text-xs font-medium text-zinc-700 dark:text-zinc-300">{{ $item->getDisplayName() }}</p>
<p class="text-xs text-zinc-400">{{ $item->getHumanFileSize() }}</p>
</div>
</div>
@if ($item->collection)
<div class="absolute right-1 top-1">
<flux:badge size="sm" color="blue" class="text-[10px]!">{{ $item->collection }}</flux:badge>
</div>
@endif
</div>
@empty
<div class="col-span-full py-12 text-center">
<x-heroicon-o-photo class="mx-auto mb-3 h-12 w-12 text-zinc-300" />
<flux:text>Noch keine Medien vorhanden. Laden Sie Dateien hoch oder legen Sie externe URLs an.</flux:text>
</div>
@endforelse
</div>
@else
{{-- List View --}}
<div class="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
<table class="w-full text-sm">
<thead class="border-b border-zinc-200 bg-zinc-50 text-left text-xs text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
<tr>
<th class="w-12 px-3 py-2"></th>
<th class="px-3 py-2">Name</th>
<th class="hidden px-3 py-2 sm:table-cell">Typ</th>
<th class="hidden px-3 py-2 sm:table-cell">Quelle</th>
<th class="hidden px-3 py-2 md:table-cell">Größe</th>
<th class="hidden px-3 py-2 lg:table-cell">Sammlung</th>
<th class="px-3 py-2 text-right">Datum</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
@forelse ($this->media as $item)
<tr wire:key="dm-l-{{ $item->id }}"
class="cursor-pointer transition {{ $editingId === $item->id ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50' }}"
wire:click="startEdit({{ $item->id }})">
<td class="px-3 py-1.5">
@php
$rowThumb = $item->getThumbnailUrl() ?? ($item->isImage() && $item->isExternal() ? $item->external_url : null);
$rowVideoFrame = (! $rowThumb && $item->isVideo() && $item->isUpload()) ? $item->getUrl() : null;
@endphp
<div class="relative flex h-8 w-8 items-center justify-center overflow-hidden rounded border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
@if ($rowThumb)
<img src="{{ $rowThumb }}" class="h-full w-full object-cover" loading="lazy" />
@elseif ($rowVideoFrame)
<video class="h-full w-full object-cover" preload="metadata" muted playsinline>
<source src="{{ $rowVideoFrame }}#t=1" type="{{ $item->mime_type }}">
</video>
@elseif ($item->isVideo())
<x-heroicon-s-film class="h-4 w-4 text-purple-500" />
@else
<x-heroicon-s-link class="h-4 w-4 text-blue-500" />
@endif
</div>
</td>
<td class="px-3 py-1.5">
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $item->getDisplayName() }}</span>
</td>
<td class="hidden px-3 py-1.5 sm:table-cell">
<flux:badge size="sm" :color="$item->isVideo() ? 'purple' : 'sky'">
{{ $item->isVideo() ? 'Video' : 'Bild' }}
</flux:badge>
</td>
<td class="hidden px-3 py-1.5 sm:table-cell">
<flux:badge size="sm" :color="$item->isExternal() ? 'blue' : 'zinc'">
{{ $item->isExternal() ? 'Extern' : 'Upload' }}
</flux:badge>
</td>
<td class="hidden px-3 py-1.5 text-zinc-500 md:table-cell">{{ $item->getHumanFileSize() }}</td>
<td class="hidden px-3 py-1.5 lg:table-cell">
@if ($item->collection)
<flux:badge size="sm" color="blue">{{ $item->collection }}</flux:badge>
@else
<span class="text-zinc-300">—</span>
@endif
</td>
<td class="px-3 py-1.5 text-right text-zinc-400">{{ $item->created_at->format('d.m.Y') }}</td>
</tr>
@empty
<tr>
<td colspan="7" class="py-12 text-center">
<x-heroicon-o-photo class="mx-auto mb-3 h-12 w-12 text-zinc-300" />
<flux:text>Noch keine Medien vorhanden.</flux:text>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endif
@if ($this->media->hasPages())
<div class="mt-4">
{{ $this->media->links() }}
</div>
@endif
</div>
{{-- Detail Sidebar --}}
@if ($showDetail && $editingId)
@php $editMedia = \App\Models\DisplayMedia::find($editingId); @endphp
@if ($editMedia)
<div class="lg:col-span-1">
<flux:card>
<div class="mb-4 flex items-center justify-between">
<flux:heading size="sm">Details</flux:heading>
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="closeDetail" />
</div>
{{-- Preview --}}
<div class="mb-4 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
@if ($editMedia->isImage())
<img src="{{ $editMedia->getUrl() }}" alt="{{ $editMedia->filename }}"
class="w-full object-contain" style="max-height: 300px;" />
@elseif ($editMedia->isVideo() && $editMedia->isUpload())
<video controls preload="metadata" class="w-full" style="max-height: 300px;"
@if ($editMedia->getThumbnailUrl()) poster="{{ $editMedia->getThumbnailUrl() }}" @endif>
<source src="{{ $editMedia->getUrl() }}" type="{{ $editMedia->mime_type }}">
</video>
@elseif ($editMedia->isExternal())
<div class="flex flex-col items-center justify-center gap-3 py-8">
<x-heroicon-o-link class="h-12 w-12 text-blue-400" />
<span class="text-sm text-zinc-500">Externe Ressource (Vorschau nicht einbettbar)</span>
</div>
@endif
</div>
{{-- Metadata --}}
<div class="mb-4 space-y-1 text-xs text-zinc-500 dark:text-zinc-400">
<p><strong>Datei:</strong> {{ $editMedia->filename }}</p>
<p><strong>Typ:</strong>
<flux:badge size="sm" :color="$editMedia->isVideo() ? 'purple' : 'sky'" class="inline">
{{ $editMedia->isVideo() ? 'Video' : 'Bild' }}
</flux:badge>
</p>
<p><strong>Quelle:</strong>
<flux:badge size="sm" :color="$editMedia->isExternal() ? 'blue' : 'zinc'" class="inline">
{{ $editMedia->isExternal() ? 'Extern' : 'Upload' }}
</flux:badge>
</p>
@if ($editMedia->isUpload())
<p><strong>Größe:</strong> {{ $editMedia->getHumanFileSize() }}</p>
@if ($editMedia->mime_type)
<p><strong>MIME:</strong> {{ $editMedia->mime_type }}</p>
@endif
@if ($editMedia->metadata && isset($editMedia->metadata['width']))
<p><strong>Abmessungen:</strong> {{ $editMedia->metadata['width'] }}×{{ $editMedia->metadata['height'] }} px</p>
@endif
@endif
<p><strong>Angelegt:</strong> {{ $editMedia->created_at->format('d.m.Y H:i') }}</p>
<p class="break-all"><strong>URL:</strong>
<a href="{{ $editMedia->getUrl() }}" target="_blank" class="text-blue-500 hover:underline">
{{ Str::limit($editMedia->getUrl(), 80) }}
</a>
</p>
</div>
{{-- Edit Form --}}
<div class="space-y-3">
<flux:input wire:model="editTitle" label="Titel" size="sm" placeholder="Anzeigename..." />
<flux:input wire:model="editAltText" label="Alt-Text" size="sm" placeholder="Bildbeschreibung..." />
<flux:input wire:model="editCollection" label="Sammlung" size="sm" placeholder="z.B. immobilien, moebel, brand..." />
<flux:button size="sm" variant="primary" wire:click="saveEdit" class="w-full">
Speichern
</flux:button>
</div>
<div class="mt-5 border-t border-zinc-200 pt-4 dark:border-zinc-700">
<flux:button size="sm" variant="danger" class="w-full" icon="trash"
wire:click="deleteMedia({{ $editMedia->id }})"
wire:confirm="Dieses Medium wirklich löschen?">
Löschen
</flux:button>
</div>
</flux:card>
</div>
@endif
@endif
</div>
{{-- External URL Modal --}}
<flux:modal wire:model="showUrlModal" class="max-w-lg">
<form wire:submit.prevent="saveExternalUrl">
<div class="space-y-6">
<div>
<flux:heading size="lg">Externe URL anlegen</flux:heading>
<flux:text class="mt-1">Binden Sie große Videos oder Medien von Google Drive, OneDrive oder anderen Quellen ein.</flux:text>
</div>
<div class="space-y-4">
<div>
<flux:input wire:model="urlInput" label="URL" placeholder="https://drive.google.com/file/d/..." />
<flux:error name="urlInput" />
</div>
<div class="flex items-end gap-3">
<flux:button type="button" wire:click="validateUrl" size="sm" variant="ghost" icon="arrow-path">
URL prüfen
</flux:button>
@if ($urlValidated === true)
<span class="flex items-center gap-1 text-sm text-green-600">
<x-heroicon-s-check-circle class="h-4 w-4" /> Erreichbar
</span>
@elseif ($urlValidated === false)
<span class="flex items-center gap-1 text-sm text-amber-600">
<x-heroicon-s-exclamation-triangle class="h-4 w-4" /> Nicht erreichbar trotzdem speichern möglich
</span>
@endif
</div>
<flux:select wire:model="urlType" label="Medientyp">
<option value="video">Video</option>
<option value="image">Bild</option>
</flux:select>
<flux:input wire:model="urlTitle" label="Titel (optional)" placeholder="z.B. Showroom-Rundgang 4K" />
<flux:input wire:model="urlCollection" label="Sammlung (optional)" placeholder="z.B. immobilien, moebel..." />
</div>
<div class="flex justify-end gap-3 pt-4">
<flux:button type="button" wire:click="$set('showUrlModal', false)" variant="ghost">
Abbrechen
</flux:button>
<flux:button type="submit" variant="primary">
Anlegen
</flux:button>
</div>
</div>
</form>
</flux:modal>
</div>