441 lines
23 KiB
PHP
441 lines
23 KiB
PHP
<?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>
|