b2in/resources/views/livewire/admin/cms/display-media-library.blade.php

541 lines
26 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 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,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 }})">
<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>