278 lines
10 KiB
PHP
278 lines
10 KiB
PHP
<?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>
|