presseportale/resources/views/livewire/components/press-release-images-manager.blade.php
Kevin Adametz a7c30d4ecc Titelbild-Upload: Bildrechte in 5 Schritten + große Bildvorschau
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:34:40 +00:00

529 lines
26 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\ImageLicenseType;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Services\Image\ImageService;
use Flux\Flux;
use Illuminate\Validation\Rule;
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 string $newAuthor = '';
public string $newLicenseType = '';
public string $newLicenseDetail = '';
public string $newLicenseUrl = '';
public string $newSourceUrl = '';
public string $newPeopleRightsStatus = '';
public string $newPropertyRightsStatus = '';
public string $newRightsNotes = '';
public bool $newRightsConfirmed = false;
public bool $isUploadFormOpen = false;
public function mount(int $pressReleaseId): void
{
$this->pressReleaseId = $pressReleaseId;
}
public function openUploadForm(): void
{
$this->isUploadFormOpen = true;
}
public function closeUploadForm(): void
{
$this->resetUploadForm();
$this->resetErrorBag();
$this->isUploadFormOpen = false;
}
public function saveImage(ImageService $imageService): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (!$this->canChangeImages($pressRelease)) {
$this->addError('newImage', __('Das Titelbild kann nur bei Entwürfen oder abgelehnten PMs geändert werden.'));
return;
}
if ($this->titleImageFor($pressRelease) !== null) {
$this->addError('newImage', __('Bitte löschen Sie zuerst das vorhandene Titelbild.'));
return;
}
$licenseType = ImageLicenseType::tryFrom($this->newLicenseType);
$requiresLicenseUrl = $licenseType?->requiresLicenseUrl() ?? false;
$requiresLicenseDetail = $licenseType?->requiresLicenseDetail() ?? false;
$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'],
'newAuthor' => ['required', 'string', 'max:255'],
'newLicenseType' => ['required', Rule::enum(ImageLicenseType::class)],
'newLicenseDetail' => [$requiresLicenseDetail ? 'required' : 'nullable', 'string', 'max:120'],
'newLicenseUrl' => [$requiresLicenseUrl ? 'required' : 'nullable', 'url', 'max:2048'],
'newSourceUrl' => ['nullable', 'url', 'max:2048'],
'newPeopleRightsStatus' => ['required', Rule::in(array_keys($this->peopleRightsOptions()))],
'newPropertyRightsStatus' => ['required', Rule::in(array_keys($this->propertyRightsOptions()))],
'newRightsNotes' => ['nullable', 'string', 'max:1000'],
'newRightsConfirmed' => ['accepted'],
],
[
'newAuthor.required' => __('Bitte Urheber, Fotograf oder Rechteinhaber angeben.'),
'newLicenseType.required' => __('Bitte einen Lizenztyp wählen.'),
'newLicenseDetail.required' => __('Bitte die Lizenz genauer angeben.'),
'newLicenseUrl.required' => __('Für diesen Lizenztyp ist eine Nachweis-URL erforderlich.'),
'newPeopleRightsStatus.required' => __('Bitte angeben, ob erkennbare Personen abgebildet sind.'),
'newPropertyRightsStatus.required' => __('Bitte angeben, ob Marken, Kunstwerke oder private Orte sichtbar sind.'),
'newRightsConfirmed.accepted' => __('Bitte bestätigen, dass die Bildrechte geklärt sind.'),
],
);
$stored = $imageService->storePressReleaseImage($this->newImage, $pressRelease->id);
$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,
'author' => $this->newAuthor,
'license_type' => $this->newLicenseType,
'license_detail' => $this->newLicenseDetail ?: null,
'license_url' => $this->newLicenseUrl ?: null,
'source_url' => $this->newSourceUrl ?: null,
'persons_consent' => $this->newPeopleRightsStatus === 'consent',
'people_rights_status' => $this->newPeopleRightsStatus,
'property_rights_status' => $this->newPropertyRightsStatus,
'rights_notes' => $this->newRightsNotes ?: null,
'rights_confirmed_at' => now(),
'is_preview' => true,
'sort_order' => ((int) $pressRelease->images()->max('sort_order')) + 1,
'width' => $stored['width'],
'height' => $stored['height'],
'mime' => $stored['mime'],
]);
$this->resetUploadForm();
$this->isUploadFormOpen = false;
$this->dispatch('title-image-changed');
Flux::toast(text: __('Titelbild hochgeladen.'), variant: 'success');
}
public function removeNewImage(): void
{
$this->reset('newImage');
$this->resetErrorBag('newImage');
}
public function newImagePreviewUrl(): ?string
{
if (!is_object($this->newImage) || !method_exists($this->newImage, 'temporaryUrl')) {
return null;
}
try {
return $this->newImage->temporaryUrl();
} catch (\Throwable) {
return null;
}
}
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();
$this->dispatch('title-image-changed');
Flux::toast(text: __('Titelbild entfernt.'), variant: 'success');
}
public function with(): array
{
$pressRelease = $this->getPressRelease();
return [
'titleImage' => $this->titleImageFor($pressRelease),
'canEdit' => auth()->user()?->can('update', $pressRelease) === true && $this->canChangeImages($pressRelease),
'licenseTypeOptions' => ImageLicenseType::options(),
'ccLicenseOptions' => $this->ccLicenseOptions(),
'peopleRightsOptions' => $this->peopleRightsOptions(),
'propertyRightsOptions' => $this->propertyRightsOptions(),
'licenseUrlRequired' => ImageLicenseType::tryFrom($this->newLicenseType)?->requiresLicenseUrl() ?? false,
'licenseDetailRequired' => ImageLicenseType::tryFrom($this->newLicenseType)?->requiresLicenseDetail() ?? false,
'showsCcWarning' => $this->newLicenseType === ImageLicenseType::CreativeCommons->value,
'showsRightsWarning' => $this->shouldShowRightsWarning(),
];
}
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);
}
private function titleImageFor(PressRelease $pressRelease): ?PressReleaseImage
{
return $pressRelease->images()->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first();
}
private function resetUploadForm(): void
{
$this->reset(['newImage', 'newTitle', 'newCopyright', 'newAuthor', 'newLicenseType', 'newLicenseDetail', 'newLicenseUrl', 'newSourceUrl', 'newPeopleRightsStatus', 'newPropertyRightsStatus', 'newRightsNotes', 'newRightsConfirmed']);
}
/**
* @return array<string, string>
*/
private function ccLicenseOptions(): array
{
return [
'cc0' => 'CC0',
'cc_by' => 'CC BY',
'cc_by_sa' => 'CC BY-SA',
'cc_by_nd' => 'CC BY-ND',
'cc_by_nc' => 'CC BY-NC',
'cc_by_nc_sa' => 'CC BY-NC-SA',
'cc_by_nc_nd' => 'CC BY-NC-ND',
];
}
/**
* @return array<string, string>
*/
private function peopleRightsOptions(): array
{
return [
'none' => __('Nein, keine erkennbaren Personen'),
'consent' => __('Ja, Einwilligung liegt vor'),
'public_event' => __('Ja, öffentliche Veranstaltung / redaktioneller Kontext'),
];
}
/**
* @return array<string, string>
*/
private function propertyRightsOptions(): array
{
return [
'none' => __('Nein'),
'cleared' => __('Ja, Rechte / Nutzung sind geklärt'),
];
}
private function shouldShowRightsWarning(): bool
{
$restrictedCcLicense = str_contains($this->newLicenseDetail, '_nc') || str_contains($this->newLicenseDetail, '_nd');
return $this->newLicenseType === ImageLicenseType::Other->value || $restrictedCcLicense;
}
}; ?>
<flux:card>
<div class="flex items-center justify-between">
<flux:heading size="md">{{ __('Titelbild') }}</flux:heading>
<flux:badge color="{{ $titleImage ? 'green' : 'zinc' }}" size="sm">
{{ $titleImage ? __('gesetzt') : __('Platzhalter aktiv') }}
</flux:badge>
</div>
@if ($titleImage)
<div class="mt-4 overflow-hidden rounded-md border border-zinc-200 dark:border-zinc-700">
<div class="relative aspect-[16/9] bg-zinc-50 dark:bg-zinc-800">
@php
$titleImageUrl =
$titleImage->variantUrl('cover') ??
($titleImage->variantUrl('large') ?? ($titleImage->variantUrl('medium') ?? $titleImage->url()));
@endphp
@if ($titleImageUrl)
<img src="{{ $titleImageUrl }}" alt="{{ $titleImage->title ?? __('Titelbild') }}"
class="absolute inset-0 size-full object-cover" loading="lazy" />
@endif
</div>
<div class="space-y-3 p-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 space-y-1">
<div class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{{ $titleImage->title ?: __('Eigenes Titelbild') }}
</div>
@if ($titleImage->author)
<p class="m-0 truncate text-xs text-zinc-500" title="{{ $titleImage->author }}">©
{{ $titleImage->author }}</p>
@endif
@if ($titleImage->copyright)
<p class="m-0 truncate text-xs text-zinc-500" title="{{ $titleImage->copyright }}">
{{ __('Bildnachweis: :copyright', ['copyright' => $titleImage->copyright]) }}
</p>
@endif
<div class="flex flex-wrap items-center gap-1 text-xs text-zinc-400">
@if ($titleImage->license_type)
<flux:badge color="zinc" size="xs">{{ $titleImage->license_type->label() }}
</flux:badge>
@endif
@if ($titleImage->width && $titleImage->height)
<span>{{ $titleImage->width }}×{{ $titleImage->height }}</span>
@endif
</div>
</div>
@if ($canEdit)
<flux:button size="sm" variant="filled" icon="trash"
wire:click="remove({{ $titleImage->id }})"
wire:confirm="{{ __('Titelbild wirklich entfernen? Danach wird wieder der Platzhalter verwendet.') }}">
{{ __('Titelbild löschen') }}
</flux:button>
@endif
</div>
</div>
</div>
@elseif($canEdit)
@if (!$isUploadFormOpen)
<div
class="mt-4 flex flex-col gap-4 rounded-md border border-dashed border-zinc-300 p-5 dark:border-zinc-700 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<flux:heading size="xs">{{ __('Hier fehlt ein Titelbild') }}</flux:heading>
<flux:text class="text-sm text-zinc-500">
{{ __('Der Platzhalter bleibt aktiv, bis ein eigenes Titelbild hochgeladen wurde.') }}
</flux:text>
</div>
<flux:button type="button" variant="primary" icon="arrow-up-tray" wire:click="openUploadForm">
{{ __('Eigenes Titelbild hochladen') }}
</flux:button>
</div>
@else
<form wire:submit="saveImage" class="mt-4 space-y-6 rounded-md border border-zinc-200 p-4 dark:border-zinc-700 sm:p-5">
<flux:heading size="xs">{{ __('Titelbild hochladen') }}</flux:heading>
{{-- ===== Schritt 1 · Bild auswählen ===== --}}
<section class="space-y-3">
<div class="flex items-center gap-2">
<span class="flex size-5 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-[11px] font-bold text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">1</span>
<span class="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{{ __('Bild auswählen') }}</span>
</div>
@if (! $newImage)
<flux:file-upload wire:model="newImage" accept="image/jpeg,image/png,image/webp"
:description="__('JPG/PNG/WebP, max. 16 MB. Wird als Titelbild gespeichert und ersetzt den Platzhalter.')">
<flux:file-upload.dropzone :heading="__('Bild hierher ziehen oder klicken')"
:text="__('JPG, PNG oder WebP · max. 16 MB')" with-progress />
</flux:file-upload>
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('Bitte laden Sie nur Bilder hoch, für die Sie die erforderlichen Nutzungsrechte besitzen. Bilder aus Google, Social Media, Messenger-Gruppen oder fremden Websites dürfen nicht ohne ausdrückliche Erlaubnis verwendet werden.') }}
</div>
@else
{{-- Große Vorschau im Titelbild-Format, damit das Motiv
vor dem Hochladen wirklich beurteilbar ist. --}}
<div class="overflow-hidden rounded-md border border-zinc-200 dark:border-zinc-700">
<div class="relative aspect-[16/9] bg-zinc-50 dark:bg-zinc-800">
@if ($this->newImagePreviewUrl())
<img src="{{ $this->newImagePreviewUrl() }}" alt="{{ __('Vorschau des gewählten Titelbilds') }}"
class="absolute inset-0 size-full object-cover" />
@else
<div class="absolute inset-0 flex items-center justify-center">
<flux:icon.photo class="size-10 text-zinc-400" />
</div>
@endif
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-zinc-200 px-3 py-2 dark:border-zinc-700">
<div class="min-w-0 text-xs text-zinc-500">
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $newImage->getClientOriginalName() }}</span>
<span class="mx-1">·</span>
<span>{{ number_format($newImage->getSize() / 1048576, 2, ',', '.') }} MB</span>
</div>
<flux:button size="xs" variant="filled" icon="arrow-path" wire:click="removeNewImage">
{{ __('Anderes Bild wählen') }}
</flux:button>
</div>
</div>
@endif
<flux:error name="newImage" />
</section>
{{-- ===== Schritt 2 · Öffentliche Bildinfos ===== --}}
<section class="space-y-3 border-t border-zinc-200 pt-5 dark:border-zinc-700">
<div class="flex items-center gap-2">
<span class="flex size-5 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-[11px] font-bold text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">2</span>
<span class="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{{ __('Bildinformationen (öffentlich sichtbar)') }}</span>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="newTitle" :label="__('Titel / Alt-Text (optional)')" />
<flux:input wire:model="newCopyright" :label="__('Öffentlicher Bildnachweis')"
:description="__('Wird öffentlich angezeigt, z. B. Foto: Max Mustermann / Beispiel GmbH.')" />
</div>
</section>
{{-- ===== Schritt 3 · Herkunft & Lizenz ===== --}}
<section class="space-y-3 border-t border-zinc-200 pt-5 dark:border-zinc-700">
<div class="flex items-center gap-2">
<span class="flex size-5 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-[11px] font-bold text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">3</span>
<span class="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{{ __('Herkunft & Lizenz') }}</span>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="newAuthor" :label="__('Urheber / Fotograf / Rechteinhaber')" :badge="__('Pflicht')"
required />
<flux:select wire:model.live="newLicenseType" :label="__('Lizenztyp')" :badge="__('Pflicht')" required>
<flux:select.option value="" disabled>{{ __('Bitte wählen…') }}</flux:select.option>
@foreach ($licenseTypeOptions as $value => $label)
<flux:select.option :value="$value">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
</div>
@if ($newLicenseType === \App\Enums\ImageLicenseType::CreativeCommons->value)
<flux:select wire:model.live="newLicenseDetail" :label="__('Creative-Commons-Lizenz')" required>
<flux:select.option value="" disabled>{{ __('Bitte wählen…') }}</flux:select.option>
@foreach ($ccLicenseOptions as $value => $label)
<flux:select.option :value="$value">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('Creative-Commons-Lizenzen können Einschränkungen enthalten. Bitte prüfen Sie, ob kommerzielle Nutzung, Bearbeitung und Veröffentlichung als Titelbild erlaubt sind.') }}
</div>
@elseif($licenseDetailRequired)
<flux:input wire:model="newLicenseDetail" :label="__('Lizenzdetails / Begründung')"
:description="__('Bitte kurz erklären, warum die Nutzung erlaubt ist.')" required />
@endif
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="newLicenseUrl" type="url"
:label="$licenseUrlRequired ? __('Quelle oder Lizenznachweis-URL') : __(
'Quelle oder Lizenznachweis-URL (optional)')"
:required="$licenseUrlRequired" placeholder="https://…" />
<flux:input wire:model="newSourceUrl" type="url" :label="__('Weitere Quelle / Fundstelle (optional)')"
placeholder="https://…" />
</div>
</section>
{{-- ===== Schritt 4 · Personen & Rechte Dritter ===== --}}
<section class="space-y-3 border-t border-zinc-200 pt-5 dark:border-zinc-700">
<div class="flex items-center gap-2">
<span class="flex size-5 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-[11px] font-bold text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">4</span>
<span class="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{{ __('Personen & Rechte Dritter') }}</span>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<flux:radio.group wire:model.live="newPeopleRightsStatus"
:label="__('Sind erkennbare Personen abgebildet?')" required>
@foreach ($peopleRightsOptions as $value => $label)
<flux:radio :value="$value" :label="$label" />
@endforeach
</flux:radio.group>
<flux:radio.group wire:model.live="newPropertyRightsStatus"
:label="__('Sind Marken, Kunstwerke, geschützte Werke oder private Orte sichtbar?')" required>
@foreach ($propertyRightsOptions as $value => $label)
<flux:radio :value="$value" :label="$label" />
@endforeach
</flux:radio.group>
</div>
@if (in_array($newPeopleRightsStatus, ['consent', 'public_event'], true))
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('Bei erkennbaren Personen können zusätzlich Persönlichkeits- oder Datenschutzrechte betroffen sein. Bitte stellen Sie sicher, dass die Veröffentlichung zulässig ist.') }}
</div>
@endif
@if ($showsRightsWarning)
<div
class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs leading-relaxed text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
{{ __('Diese Auswahl kann Einschränkungen enthalten. Bitte laden Sie das Bild nur hoch, wenn die Nutzung, Bearbeitung und Veröffentlichung als Titelbild erlaubt ist.') }}
</div>
@endif
</section>
{{-- ===== Schritt 5 · Bestätigung ===== --}}
<section class="space-y-3 border-t border-zinc-200 pt-5 dark:border-zinc-700">
<div class="flex items-center gap-2">
<span class="flex size-5 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-[11px] font-bold text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">5</span>
<span class="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{{ __('Bestätigung') }}</span>
</div>
<flux:textarea wire:model="newRightsNotes"
:label="__('Interne Hinweise zu Rechten / Freigaben (optional)')" rows="3" />
<flux:switch wire:model="newRightsConfirmed" align="right" :label="__('Rechte bestätigt')"
:description="__('Ich bestätige, dass ich über die erforderlichen Rechte zur Veröffentlichung dieses Bildes verfüge und die Verantwortung für die Richtigkeit meiner Angaben übernehme. Dies umfasst Urheberrechte, Nutzungsrechte, Persönlichkeitsrechte abgebildeter Personen sowie gegebenenfalls Marken-, Eigentums- oder sonstige Rechte Dritter. Mir ist bewusst, dass ich für fehlerhafte oder unvollständige Angaben verantwortlich bin.')" />
<div class="flex justify-end gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-700">
<flux:button type="button" variant="filled" wire:click="closeUploadForm">{{ __('Abbrechen') }}</flux:button>
<flux:button type="submit" variant="primary" icon="arrow-up-tray">{{ __('Hochladen') }}</flux:button>
</div>
</section>
</form>
@endif
@else
<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 kein eigenes Titelbild hinterlegt. Der Platzhalter bleibt aktiv.') }}</flux:text>
</div>
@endif
</flux:card>