10-04-2026

This commit is contained in:
Kevin Adametz 2026-04-10 17:18:17 +02:00
parent 4d6b4930b2
commit 4bb89aad8c
836 changed files with 52961 additions and 5950 deletions

View file

@ -0,0 +1,541 @@
<?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 function Livewire\Volt\{layout, title, state, computed, on, uses};
layout('components.layouts.app');
title('Display-Mediathek');
uses([WithFileUploads::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->where('filename', 'like', "%{$this->search}%")
->orWhere('title', 'like', "%{$this->search}%"))
->orderByDesc('created_at')
->paginate(48),
);
$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,mp4,webm,mov|max:51200',
]);
$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,video/mp4,video/webm,.jpg,.jpeg,.png,.webp,.mp4,.webm,.mov">
<flux:file-upload.dropzone
heading="Dateien hochladen"
text="Bilder & Videos bis 50 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 }})">
<div class="aspect-square bg-zinc-100 dark:bg-zinc-800">
@if ($item->isImage() && $item->isUpload())
<img src="{{ $item->getThumbnailUrl() }}"
alt="{{ $item->alt_text ?? $item->filename }}"
class="h-full w-full object-cover" loading="lazy" />
@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>
@elseif ($item->isExternal() && $item->isImage())
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-blue-400">
<x-heroicon-o-photo class="h-10 w-10" />
<span class="text-xs">Extern</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
</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">
<div class="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 ($item->isImage() && $item->isUpload())
<img src="{{ $item->getThumbnailUrl() }}" class="h-full w-full object-cover" loading="lazy" />
@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() && $editMedia->isUpload())
<img src="{{ $editMedia->getUrl() }}" alt="{{ $editMedia->filename }}"
class="w-full object-contain" style="max-height: 300px;" />
@elseif ($editMedia->isVideo() && $editMedia->isUpload())
<video controls class="w-full" style="max-height: 300px;">
<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</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>