b2in/resources/views/livewire/admin/cms/media-index.blade.php
2026-04-10 17:18:17 +02:00

441 lines
23 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 Flux\Flux;
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\MediaConversionService;
use Illuminate\Support\Facades\Storage;
use function Livewire\Volt\{layout, title, state, computed, on};
layout('components.layouts.app');
title('Medienbibliothek');
state([
'search' => '',
'filterType' => 'all',
'filterCollection' => '',
'viewMode' => 'grid',
'editingId' => null,
'editLocale' => 'de',
'altText' => '',
'mediaTitle' => '',
'collection' => '',
'showDetail' => false,
'selectedProfiles' => [],
]);
$media = computed(
fn () => CmsMedia::query()
->when(
$this->filterType !== 'all',
fn ($q) => match ($this->filterType) {
'image' => $q->images(),
'pdf' => $q->pdfs(),
'document' => $q->documents(),
default => $q,
},
)
->when($this->filterCollection, fn ($q) => $q->inCollection($this->filterCollection))
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%"))
->orderByDesc('created_at')
->paginate(48),
);
$collections = computed(fn () => CmsMedia::query()->whereNotNull('collection')->where('collection', '!=', '')->distinct()->pluck('collection')->sort()->values()->toArray());
$profiles = computed(fn () => config('flux-cms.media.profiles', []));
$stats = computed(
fn () => [
'total' => CmsMedia::count(),
'images' => CmsMedia::images()->count(),
'pdfs' => CmsMedia::pdfs()->count(),
],
);
on([
'media-library-uploaded' => function ($mediaId) {
$media = CmsMedia::find($mediaId);
if ($media) {
Flux::toast(variant: 'success', heading: 'Hochgeladen', text: $media->filename . ' wurde erfolgreich hochgeladen.');
}
},
]);
$startEdit = function (int $id) {
$media = CmsMedia::find($id);
if (! $media) {
return;
}
$this->editingId = $id;
$this->altText = $media->getTranslation('alt_text', $this->editLocale) ?? '';
$this->mediaTitle = $media->getTranslation('title', $this->editLocale) ?? '';
$this->collection = $media->collection ?? '';
$this->showDetail = true;
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId) {
$media = CmsMedia::find($this->editingId);
if ($media) {
$this->altText = $media->getTranslation('alt_text', $locale) ?? '';
$this->mediaTitle = $media->getTranslation('title', $locale) ?? '';
}
}
};
$saveEdit = function () {
$media = CmsMedia::find($this->editingId);
if (! $media) {
return;
}
$media->setTranslation('alt_text', $this->editLocale, $this->altText);
$media->setTranslation('title', $this->editLocale, $this->mediaTitle);
$media->collection = $this->collection;
$media->save();
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Medien-Details wurden aktualisiert.');
};
$generateConversion = function (string $profile) {
$media = CmsMedia::find($this->editingId);
if (! $media || ! $media->isImage()) {
return;
}
$service = app(MediaConversionService::class);
$result = $service->convert($media, $profile);
if ($result) {
$media->refresh();
Flux::toast(variant: 'success', heading: 'Conversion erstellt', text: "Profil \"{$profile}\" wurde generiert.");
} else {
Flux::toast(variant: 'danger', heading: 'Fehler', text: "Conversion \"{$profile}\" konnte nicht erstellt werden.");
}
};
$generateAllConversions = function () {
$media = CmsMedia::find($this->editingId);
if (! $media || ! $media->isImage()) {
return;
}
$service = app(MediaConversionService::class);
$results = $service->generateAllConversions($media);
$count = count(array_filter($results));
$media->refresh();
Flux::toast(variant: 'success', heading: 'Alle Conversions erstellt', text: "{$count} Profile wurden generiert.");
};
$deleteMedia = function (int $id) {
$media = CmsMedia::find($id);
if (! $media) {
return;
}
$filename = $media->filename;
$service = app(MediaConversionService::class);
$service->deleteAll($media);
$media->delete();
$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">
<flux:heading size="xl">Medienbibliothek</flux:heading>
<div class="flex items-center gap-3">
<flux:badge color="blue">{{ $this->stats['images'] }} Bilder</flux:badge>
<flux:badge color="amber">{{ $this->stats['pdfs'] }} PDFs</flux:badge>
</div>
</div>
<flux:card class="mb-6">
<div class="flex items-center gap-4">
<div class="flex-1">
<livewire:admin.cms.media-library-uploader key="media-lib-uploader" />
</div>
</div>
</flux:card>
<div class="mb-4 flex flex-wrap items-center gap-3">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Dateiname 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="pdf">PDFs</flux:select.option>
<flux:select.option value="document">Dokumente</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 Ordner</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>
<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="media-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())
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
alt="{{ $item->getTranslation('alt_text', $editLocale) ?? $item->filename }}"
class="h-full w-full object-cover" loading="lazy" />
@elseif ($item->isPdf())
<div class="relative h-full w-full">
<iframe src="{{ $item->getUrl() }}#toolbar=0&navpanes=0&scrollbar=0&view=FitH"
class="pointer-events-none h-full w-full scale-100 bg-white"
loading="lazy"></iframe>
<div class="absolute inset-0"></div>
</div>
@else
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-zinc-400">
<x-heroicon-o-document class="h-10 w-10" />
<span class="text-xs">{{ strtoupper(pathinfo($item->filename, PATHINFO_EXTENSION)) }}</span>
</div>
@endif
</div>
<div class="flex items-center gap-1.5 p-2">
@if ($item->isImage())
<x-heroicon-s-photo class="h-3.5 w-3.5 shrink-0 text-blue-500" />
@elseif ($item->isPdf())
<x-heroicon-s-document-text class="h-3.5 w-3.5 shrink-0 text-red-500" />
@else
<x-heroicon-s-document class="h-3.5 w-3.5 shrink-0 text-zinc-400" />
@endif
<div class="min-w-0">
<p class="truncate text-xs font-medium text-zinc-700 dark:text-zinc-300">{{ $item->filename }}</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 hochgeladen.</flux:text>
</div>
@endforelse
</div>
@else
<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">Dateiname</th>
<th class="hidden px-3 py-2 sm:table-cell">Typ</th>
<th class="hidden px-3 py-2 md:table-cell">Größe</th>
<th class="hidden px-3 py-2 md:table-cell">Abmessungen</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="media-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="h-8 w-8 overflow-hidden rounded border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
@if ($item->isImage())
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
class="h-full w-full object-cover" loading="lazy" />
@elseif ($item->isPdf())
<div class="relative h-full w-full">
<iframe src="{{ $item->getUrl() }}#toolbar=0&navpanes=0&scrollbar=0&view=FitH"
class="pointer-events-none h-full w-full bg-white"
loading="lazy"></iframe>
</div>
@else
<div class="flex h-full w-full items-center justify-center text-zinc-400">
<x-heroicon-s-document class="h-4 w-4" />
</div>
@endif
</div>
</td>
<td class="px-3 py-1.5">
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $item->filename }}</span>
</td>
<td class="hidden px-3 py-1.5 text-zinc-500 sm:table-cell">
<flux:badge size="sm" :color="$item->isImage() ? 'blue' : ($item->isPdf() ? 'amber' : 'zinc')">
{{ $item->type }}
</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 text-zinc-500 md:table-cell">{{ $item->getDimensionsLabel() ?: '—' }}</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 hochgeladen.</flux:text>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endif
@if ($this->media->hasPages())
<div class="mt-4">
{{ $this->media->links() }}
</div>
@endif
</div>
@if ($showDetail && $editingId)
@php $editMedia = \FluxCms\Core\Models\CmsMedia::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>
<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->isPdf())
<iframe src="{{ $editMedia->getUrl() }}#toolbar=0&navpanes=0"
class="h-64 w-full bg-white" loading="lazy"></iframe>
@endif
</div>
<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> {{ $editMedia->mime_type }}</p>
<p><strong>Größe:</strong> {{ $editMedia->getHumanFileSize() }}</p>
@if ($editMedia->getDimensionsLabel())
<p><strong>Abmessungen:</strong> {{ $editMedia->getDimensionsLabel() }} px</p>
@endif
<p><strong>Hochgeladen:</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">
{{ $editMedia->getUrl() }}
</a>
</p>
</div>
<div class="mb-3 flex gap-1">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">
{{ strtoupper($code) }}
</flux:button>
@endforeach
</div>
<div class="space-y-3">
<flux:input wire:model="mediaTitle" label="Titel" size="sm" placeholder="Anzeigename..." />
<flux:input wire:model="altText" label="Alt-Text" size="sm" placeholder="Bildbeschreibung für SEO..." />
<flux:input wire:model="collection" label="Ordner / Sammlung" size="sm" placeholder="z.B. hero, team, news..." />
<flux:button size="sm" variant="primary" wire:click="saveEdit" class="w-full">
Speichern
</flux:button>
</div>
@if ($editMedia->isImage() && $editMedia->mime_type !== 'image/svg+xml')
<div class="mt-5 border-t border-zinc-200 pt-4 dark:border-zinc-700">
<div class="mb-3 flex items-center justify-between">
<flux:heading size="sm">Bildgrößen</flux:heading>
<flux:button size="xs" variant="ghost" wire:click="generateAllConversions"
wire:loading.attr="disabled">
Alle generieren
</flux:button>
</div>
<div class="space-y-2">
@foreach ($this->profiles as $profileName => $profileConfig)
@php
$hasConversion = $editMedia->hasConversion($profileName);
$conversionUrl = $hasConversion ? $editMedia->getConversionUrl($profileName) : null;
@endphp
<div class="flex items-center justify-between rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-700">
<div>
<span class="text-sm font-medium">{{ $profileName }}</span>
<span class="text-xs text-zinc-400">
{{ $profileConfig['width'] }}×{{ $profileConfig['height'] }}
{{ strtoupper($profileConfig['format'] ?? 'webp') }}
</span>
</div>
<div class="flex items-center gap-2">
@if ($hasConversion)
<flux:badge size="sm" color="green">OK</flux:badge>
@else
<flux:badge size="sm" color="zinc">—</flux:badge>
@endif
<flux:button size="xs" variant="ghost" icon="arrow-path"
wire:click="generateConversion('{{ $profileName }}')"
wire:loading.attr="disabled" />
</div>
</div>
@endforeach
</div>
</div>
@endif
<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="'{{ $editMedia->filename }}' wirklich löschen? Alle Conversions werden ebenfalls entfernt.">
Datei löschen
</flux:button>
</div>
</flux:card>
</div>
@endif
@endif
</div>
</div>