12-05-2026 Frontend dev
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run

This commit is contained in:
Kevin Adametz 2026-05-12 18:32:33 +02:00
parent 405df0a122
commit 5b8bdf4182
779 changed files with 480564 additions and 6241 deletions

View file

@ -0,0 +1,281 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
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']);
session()->flash('image-status', __('Bild hochgeladen.'));
}
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]);
session()->flash('image-status', __('Vorschaubild gesetzt.'));
}
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();
session()->flash('image-status', __('Bild entfernt.'));
}
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(session('image-status'))
<flux:callout color="green" icon="check-circle" class="mt-3">{{ session('image-status') }}</flux:callout>
@endif
@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>