presseportale/resources/views/livewire/components/press-release-images-manager.blade.php
Kevin Adametz e8c47b7553
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
22-05-2026 Optimierung der User und Admin Panels
2026-05-22 11:18:59 +02:00

278 lines
10 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\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Flux\Flux;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
/**
* Reusable image manager for a single press release. Used by both the
* admin and customer PR edit pages. Authorisation is delegated to the
* `update` ability on `PressReleasePolicy`, so the same component is safe
* to use for admins (who can always edit) and customers (only their own).
*/
new class extends Component
{
use WithFileUploads;
#[Locked]
public int $pressReleaseId;
public $newImage = null;
public string $newTitle = '';
public string $newCopyright = '';
public bool $newIsPreview = false;
public function mount(int $pressReleaseId): void
{
$this->pressReleaseId = $pressReleaseId;
}
public function upload(ImageService $imageService): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeImages($pressRelease)) {
$this->addError('newImage', __('Bilder können nur bei Entwürfen oder abgelehnten PMs geändert werden.'));
return;
}
$this->validate([
'newImage' => ['required', 'image', 'mimes:jpeg,jpg,png,webp', 'max:'.(int) (ImageService::MAX_PRESS_RELEASE_IMAGE_BYTES / 1024)],
'newTitle' => ['nullable', 'string', 'max:120'],
'newCopyright' => ['nullable', 'string', 'max:255'],
]);
$stored = $imageService->storePressReleaseImage($this->newImage, $pressRelease->id);
if ($this->newIsPreview) {
$pressRelease->images()->update(['is_preview' => false]);
}
$pressRelease->images()->create([
'disk' => 'public',
'path' => $stored['path'],
'variants' => $stored['variants'],
'title' => $this->newTitle ?: null,
'copyright' => $this->newCopyright ?: null,
'is_preview' => $this->newIsPreview,
'sort_order' => ((int) $pressRelease->images()->max('sort_order')) + 1,
'width' => $stored['width'],
'height' => $stored['height'],
'mime' => $stored['mime'],
]);
$this->reset(['newImage', 'newTitle', 'newCopyright', 'newIsPreview']);
Flux::toast(text: __('Bild hochgeladen.'), variant: 'success');
}
public function setPreview(int $imageId): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
$image = $pressRelease->images()->whereKey($imageId)->first();
if (! $image) {
return;
}
$pressRelease->images()->where('id', '!=', $image->id)->update(['is_preview' => false]);
$image->update(['is_preview' => true]);
Flux::toast(text: __('Vorschaubild gesetzt.'), variant: 'success');
}
public function moveUp(int $imageId): void
{
$this->swapSortOrder($imageId, -1);
}
public function moveDown(int $imageId): void
{
$this->swapSortOrder($imageId, 1);
}
public function remove(int $imageId, ImageService $imageService): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeImages($pressRelease)) {
return;
}
$image = $pressRelease->images()->whereKey($imageId)->first();
if (! $image) {
return;
}
$imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
$image->delete();
Flux::toast(text: __('Bild entfernt.'), variant: 'success');
}
public function with(): array
{
$pressRelease = $this->getPressRelease();
return [
'images' => $pressRelease->images()
->orderBy('sort_order')
->orderBy('id')
->get(),
'canEdit' => auth()->user()?->can('update', $pressRelease) === true
&& $this->canChangeImages($pressRelease),
];
}
private function swapSortOrder(int $imageId, int $direction): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (! $this->canChangeImages($pressRelease)) {
return;
}
$images = $pressRelease->images()->orderBy('sort_order')->orderBy('id')->get();
$currentIndex = $images->search(fn (PressReleaseImage $image) => $image->id === $imageId);
if ($currentIndex === false) {
return;
}
$targetIndex = $currentIndex + $direction;
if ($targetIndex < 0 || $targetIndex >= $images->count()) {
return;
}
$current = $images[$currentIndex];
$target = $images[$targetIndex];
$currentSort = $current->sort_order;
$current->update(['sort_order' => $target->sort_order]);
$target->update(['sort_order' => $currentSort]);
}
private function getPressRelease(): PressRelease
{
return PressRelease::withoutGlobalScopes()
->findOrFail($this->pressReleaseId);
}
private function canChangeImages(PressRelease $pressRelease): bool
{
if (auth()->user()?->canAccessAdmin()) {
return ! in_array(
$pressRelease->status,
[PressReleaseStatus::Archived],
true,
);
}
return in_array(
$pressRelease->status,
[PressReleaseStatus::Draft, PressReleaseStatus::Rejected],
true,
);
}
}; ?>
<flux:card>
<div class="flex items-center justify-between">
<flux:heading size="md">{{ __('Bilder') }}</flux:heading>
<flux:badge color="zinc" size="sm">{{ count($images) }}</flux:badge>
</div>
@if($canEdit)
<form wire:submit="upload" class="mt-4 space-y-3 rounded-md border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="xs">{{ __('Neues Bild hinzufügen') }}</flux:heading>
<flux:input
type="file"
wire:model="newImage"
accept="image/jpeg,image/png,image/webp"
:description="__('JPG/PNG/WebP, max. 8 MB. Varianten thumb/medium/large werden automatisch erzeugt.')"
/>
<flux:error name="newImage" />
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="newTitle" :label="__('Titel (optional)')" />
<flux:input wire:model="newCopyright" :label="__('Copyright / Quelle (optional)')" />
</div>
<flux:checkbox wire:model="newIsPreview" :label="__('Als Vorschaubild verwenden')" />
<div class="flex justify-end">
<flux:button type="submit" variant="primary" icon="arrow-up-tray">{{ __('Hochladen') }}</flux:button>
</div>
</form>
@endif
@if($images->isEmpty())
<div class="mt-4 rounded-md border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
<flux:icon.photo class="mx-auto size-10 text-zinc-400" />
<flux:text class="mt-2 text-sm text-zinc-500">{{ __('Noch keine Bilder hinterlegt.') }}</flux:text>
</div>
@else
<div class="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
@foreach($images as $image)
<div class="group rounded-md border border-zinc-200 p-2 dark:border-zinc-700" wire:key="pri-{{ $image->id }}">
<div class="relative aspect-square overflow-hidden rounded bg-zinc-50 dark:bg-zinc-800">
@if($image->variantUrl('thumb') ?? $image->url())
<img src="{{ $image->variantUrl('thumb') ?? $image->url() }}" alt="{{ $image->title ?? '' }}" class="absolute inset-0 size-full object-cover" loading="lazy" />
@endif
@if($image->is_preview)
<flux:badge color="green" size="sm" icon="star" class="absolute left-2 top-2">
{{ __('Vorschau') }}
</flux:badge>
@endif
</div>
<div class="mt-2 space-y-1">
@if($image->title)
<p class="truncate text-sm font-medium" title="{{ $image->title }}">{{ $image->title }}</p>
@endif
@if($image->copyright)
<p class="truncate text-xs text-zinc-500">{{ $image->copyright }}</p>
@endif
<div class="flex flex-wrap items-center gap-1 text-xs text-zinc-400">
@if($image->width && $image->height)
<span>{{ $image->width }}×{{ $image->height }}</span>
@endif
@if(is_array($image->variants))
<flux:badge color="zinc" size="xs">{{ count($image->variants) }}× variant</flux:badge>
@endif
</div>
@if($canEdit)
<div class="flex flex-wrap gap-1 pt-1">
@if(! $image->is_preview)
<flux:button size="xs" variant="ghost" icon="star" wire:click="setPreview({{ $image->id }})" :title="__('Als Vorschau setzen')" />
@endif
<flux:button size="xs" variant="ghost" icon="arrow-up" wire:click="moveUp({{ $image->id }})" :title="__('Hoch')" />
<flux:button size="xs" variant="ghost" icon="arrow-down" wire:click="moveDown({{ $image->id }})" :title="__('Runter')" />
<flux:button size="xs" variant="ghost" icon="trash" wire:click="remove({{ $image->id }})"
wire:confirm="{{ __('Bild wirklich entfernen?') }}" :title="__('Entfernen')" />
</div>
@endif
</div>
</div>
@endforeach
</div>
@endif
</flux:card>