presseportale/resources/views/livewire/components/press-release-images-manager.blade.php
Kevin Adametz a000238ca8 User Panel: Phase-8-Abschluss, Titelbild/Lizenzen/Zeitzonen und KI-Pruef-Pipeline
Phase 8 (Rest) + Umbauten vom 10./11.06.:
- Ein Titelbild pro PM (Cover 1280x580), SVG-Platzhalter-Set + Picker,
  PressReleaseCoverImage-Resolver
- Lizenz-/Rechteformular nach "Lizenztyp Bildupload" (7 Lizenztypen,
  Personen-/Sachrechte-Status, bedingte Pflichtfelder, Risikohinweise)
- Veroeffentlichungs-Box vereinfacht (Embargo aus der Form-UI entfernt),
  geplante Termine in Europe/Berlin (Speicherung UTC, DISPLAY_TIMEZONE)
- Quota-Stub (users.press_release_quota) + monatlicher Reset-Command
- Einreichungs-Modal einheitlich in Show/Create/Edit; Ghost-Buttons auf
  filled; PM-Editor-Layout responsive entkoppelt (.pr-editor-layout)

KI-Pruef-Pipeline (Phasen 1-5 des Entwicklungsplans):
- API-Haertung: status nicht mehr per API setzbar, eigene Submit-Route
  durch denselben Funnel (Blacklist, Quota, Status-Log)
- Klassifikation Rot/Gelb/Gruen asynchron (Queue classification,
  OpenAI-Treiber + deterministischer Fallback), ki_audits-Audit-Log
- Routing: Rot -> rejected + Mail, Gelb -> Review-Queue, Gruen ->
  Auto-Publish; Scheduler publiziert nur gruene faellige PMs
- Content-Score 0-100 -> Stufe (Standard/Geprueft/Hochwertig) inkl.
  Editor-Panel und Badges; Re-Klassifikation/-Score bei Aenderung
- Admin: KI-Badge + Filter, On-Demand-Pruefung mit Anbieter-Override

Suite: 442 passed, 4 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:30:13 +00:00

468 lines
21 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-3 rounded-md border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="xs">{{ __('Titelbild hochladen') }}</flux:heading>
<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>
@if ($newImage)
<flux:file-item :heading="$newImage->getClientOriginalName()" :image="$this->newImagePreviewUrl()"
:size="$newImage->getSize()">
<x-slot name="actions">
<flux:file-item.remove wire:click="removeNewImage" :aria-label="__('Bild entfernen')" />
</x-slot>
</flux:file-item>
@endif
<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.')" />
<flux:separator variant="subtle" :text="__('Bildrechte')" />
<flux:input wire:model="newAuthor" :label="__('Urheber / Fotograf / Rechteinhaber (Pflichtfeld)')"
required />
<flux:select wire:model.live="newLicenseType" :label="__('Lizenztyp')" 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>
@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
<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://…" />
<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>
@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
<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>
@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
<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">
<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>
</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>