images: Titelbild-Formular v2, Admin-Upload mit Kundenbestätigung, Entfernen mit Sofortlöschung (Bildkennzeichnung Block B)

- press-release-images-manager-v2 hinter IMAGE_RIGHTS_V2: drei Blöcke, Vorbelegung aus Metadaten, Bestätigung mit archiviertem Wortlaut, Bild-Bindung über Hash, Fokuspunkt und Letterbox, Zustände absent/prefilled/error
- CustomerImageConfirmation: Magic-Link an den Kontoinhaber, Karte auf der Kundenseite, Kanal magic_link
- PressReleaseImageRemover: nie veröffentlicht = alles sofort weg, veröffentlicht = Soft Delete, Original bleibt
- ConversionSource::MailImageConfirmation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 09:31:32 +00:00
parent e9bdff3ad3
commit 40953d6c65
11 changed files with 1380 additions and 5 deletions

View file

@ -691,7 +691,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
</section>
{{-- 5) MEDIEN --}}
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
@if (config('images.rights_v2'))
<livewire:components.press-release-images-manager-v2 :press-release-id="$id" :wire:key="'pr-images-v2-'.$id" />
@else
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
@endif
{{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung

View file

@ -0,0 +1,738 @@
<?php
use App\Enums\AiImageDeclaration;
use App\Enums\ImageProvenanceStatus;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\Image\ImageRightsTextRenderer;
use App\Services\Image\ImageService;
use App\Services\PressRelease\Images\CustomerImageConfirmation;
use App\Services\PressRelease\Images\ImageSubmission;
use App\Services\PressRelease\Images\ImageUploadActor;
use App\Services\PressRelease\Images\ImageUploadException;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\PressRelease\Images\PressReleaseImageUploader;
use Flux\Flux;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
/**
* Titelbild-Manager, Formular v2 (Bildkennzeichnung Block B, Decision-Update
* 22.09.2026). Drei Blöcke: Bild, Angaben (Nachweis + KI-Kennzeichnung),
* Bestätigung. Herkunftsdaten der Datei belegen vor, sperren aber nichts
* (Phase 1). Der Uploader bestimmt alle effektiven Werte serverseitig; die
* hier gesperrten Properties sind nur der Merker für die Datei-Bindung (B.2).
*
* Admin-Upload: keine Bestätigung durch den Admin, das Bild wartet auf die
* Kundenbestätigung per Magic-Link (E10).
*/
new class extends Component {
use WithFileUploads;
#[Locked]
public int $pressReleaseId;
public $newImage = null;
public string $newCopyright = '';
public string $newAiDeclaration = '';
public string $newTitle = '';
public string $newDescription = '';
public string $newLicenseUrl = '';
public string $newSourceUrl = '';
public bool $newRightsConfirmed = false;
public bool $noCrop = false;
/** Fokuspunkt als "x,y" in Prozent (Neun-Punkte-Wahl, H.3). */
public string $focus = '50,50';
public bool $isUploadFormOpen = false;
public bool $showLegalDetails = false;
/** Zuletzt automatisch vorgeschlagener Nachweis (manuelle Eingaben bleiben). */
public string $autoCopyright = '';
#[Locked]
public ?string $provenanceStatus = null;
#[Locked]
public ?string $provenanceSha = null;
#[Locked]
public ?string $creditSuggestion = null;
#[Locked]
public ?string $aiSuggestion = null;
#[Locked]
public bool $enhancementHint = false;
public function mount(int $pressReleaseId): void
{
$this->pressReleaseId = $pressReleaseId;
}
public function openUploadForm(): void
{
$this->isUploadFormOpen = true;
}
public function updatedIsUploadFormOpen(bool $isOpen): void
{
if (!$isOpen) {
$this->resetUploadForm();
$this->resetErrorBag();
}
}
/**
* Nach der Dateiauswahl: Grenzen prüfen, Herkunft lesen, Formular
* vorbelegen. Ein Dateiwechsel setzt Vorbelegung und Schalter zurück (B.2).
*/
public function updatedNewImage(PressReleaseImageUploader $uploader): void
{
$this->resetProvenance();
$this->newRightsConfirmed = false;
$this->resetErrorBag();
if (!$this->newImage) {
return;
}
$this->validateOnly('newImage', ['newImage' => $this->imageRules()]);
try {
$result = $uploader->preflight($this->newImage);
} catch (ImageUploadException $exception) {
$this->addError('newImage', $exception->getMessage());
$this->reset('newImage');
return;
}
$this->provenanceStatus = $result->status->value;
$this->provenanceSha = $result->fileSha256;
$this->creditSuggestion = $result->creditSuggestion();
$this->aiSuggestion = $result->suggestedAiDeclaration()?->value;
$this->enhancementHint = (bool) $result->derived['enhancement_hint'];
// Nachweis vorbelegen: aus den Metadaten, sonst Firmenname — nur,
// solange der Nutzer nichts Eigenes eingetragen hat.
$suggested = $this->creditSuggestion ?? $this->companyName();
if ($this->newCopyright === '' || $this->newCopyright === $this->autoCopyright) {
$this->newCopyright = $suggested;
$this->autoCopyright = $suggested;
}
if ($this->newAiDeclaration === '' && $this->aiSuggestion !== null) {
$this->newAiDeclaration = $this->aiSuggestion;
}
}
public function closeUploadForm(): void
{
$this->resetUploadForm();
$this->resetErrorBag();
$this->isUploadFormOpen = false;
}
public function removeNewImage(): void
{
$this->reset('newImage');
$this->resetProvenance();
$this->newRightsConfirmed = false;
$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 saveImage(PressReleaseImageUploader $uploader, ImageRightsTextRenderer $texts, CustomerImageConfirmation $customerConfirmation): 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;
}
$isAdmin = $this->actsAsAdmin();
$this->validate(
[
'newImage' => $this->imageRules(),
'newCopyright' => ['required', 'string', 'max:255'],
'newAiDeclaration' => ['required', Rule::enum(AiImageDeclaration::class)],
'newTitle' => ['nullable', 'string', 'max:120'],
'newDescription' => ['nullable', 'string', 'max:500'],
'newLicenseUrl' => ['nullable', 'url:http,https', 'max:2048'],
'newSourceUrl' => ['nullable', 'url:http,https', 'max:2048'],
'newRightsConfirmed' => [$isAdmin ? 'nullable' : 'accepted'],
'focus' => ['required', Rule::in(array_keys($this->focusOptions()))],
],
[
'newCopyright.required' => __('Bitte geben Sie an, wer das Bild erstellt hat oder die Rechte hält.'),
'newAiDeclaration.required' => __('Bitte wählen Sie aus, ob das Bild mit KI erstellt oder bearbeitet wurde.'),
'newRightsConfirmed.accepted' => __('Die Bestätigung ist erforderlich, um das Bild zu veröffentlichen.'),
],
);
$legalText = $texts->current();
[$focusX, $focusY] = array_map('intval', explode(',', $this->focus));
$submission = new ImageSubmission(
copyright: trim($this->newCopyright),
aiDeclaration: AiImageDeclaration::from($this->newAiDeclaration),
rightsConfirmed: $this->newRightsConfirmed,
legalTextKey: $legalText?->key,
title: $this->newTitle ?: null,
description: $this->newDescription ?: null,
licenseUrl: $this->newLicenseUrl ?: null,
sourceUrl: $this->newSourceUrl ?: null,
focus: ['x' => $focusX, 'y' => $focusY],
noCrop: $this->noCrop,
creditFromSuggestion: $this->creditSuggestion !== null && trim($this->newCopyright) === $this->creditSuggestion,
);
$user = auth()->user();
$actor = $isAdmin
? ImageUploadActor::admin($user, request()->ip(), request()->userAgent())
: ImageUploadActor::form($user, request()->ip(), request()->userAgent());
try {
$image = $uploader->store(
$this->newImage,
$pressRelease,
$submission,
$actor,
expectedSha256: $this->provenanceSha,
agbUrl: $this->agbUrl(),
);
} catch (ImageUploadException $exception) {
$this->addError($exception->field === 'rights_confirmed' ? 'newRightsConfirmed' : 'newImage', $exception->getMessage());
return;
}
if ($isAdmin) {
$customerConfirmation->request($image, request()->ip());
}
$this->resetUploadForm();
$this->isUploadFormOpen = false;
$this->dispatch('title-image-changed');
Flux::toast(
text: $isAdmin
? __('Titelbild hochgeladen. Der Kunde wurde per E-Mail um die Bestätigung der Bildrechte gebeten.')
: __('Titelbild hochgeladen.'),
variant: 'success',
);
}
/**
* Admin: Bestätigungslink erneut an den Kontoinhaber schicken.
*/
public function resendConfirmationRequest(CustomerImageConfirmation $customerConfirmation): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
$image = $this->titleImageFor($pressRelease);
if ($image === null || !$image->awaitsCustomerConfirmation() || !$this->actsAsAdmin()) {
return;
}
$sent = $customerConfirmation->request($image, request()->ip());
Flux::toast(
text: $sent ? __('Bestätigungslink erneut gesendet.') : __('Kein Empfänger für den Bestätigungslink hinterlegt.'),
variant: $sent ? 'success' : 'warning',
);
}
public function remove(int $imageId, PressReleaseImageRemover $remover): void
{
$pressRelease = $this->getPressRelease();
$this->authorize('update', $pressRelease);
if (!$this->canChangeImages($pressRelease)) {
return;
}
$image = $pressRelease->images()->whereKey($imageId)->first();
if (!$image) {
return;
}
$remover->remove($image);
$this->dispatch('title-image-changed');
Flux::toast(text: __('Titelbild entfernt.'), variant: 'success');
}
public function with(ImageRightsTextRenderer $texts): array
{
$pressRelease = $this->getPressRelease();
$titleImage = $this->titleImageFor($pressRelease);
$legalText = $texts->current();
$agbUrl = $this->agbUrl();
return [
'titleImage' => $titleImage,
'latestConfirmation' => $titleImage?->latestRightsConfirmation,
'awaitsCustomerConfirmation' => $titleImage?->awaitsCustomerConfirmation() ?? false,
'canEdit' => auth()->user()?->can('update', $pressRelease) === true && $this->canChangeImages($pressRelease),
'isAdminActor' => $this->actsAsAdmin(),
'companyName' => $this->companyName(),
'legalTextKey' => $legalText?->key,
'legalLong' => $legalText ? $texts->renderLong($legalText, $agbUrl) : null,
'legalShort' => $legalText ? $texts->renderShort($legalText, $agbUrl) : null,
'provenanceState' => $this->provenanceState(),
'aiOptions' => AiImageDeclaration::cases(),
'focusOptions' => $this->focusOptions(),
'maxMb' => (int) round((int) config('images.max_bytes') / 1024 / 1024),
];
}
/**
* @return array<string, string>
*/
private function imageRules(): array
{
return [
'required',
'image',
'mimes:jpeg,jpg,png,webp',
'max:' . (int) ((int) config('images.max_bytes') / 1024),
];
}
/**
* Zustand des Prüfhinweises im Formular (B.1): B keine Daten, C
* vorbelegt, D nicht prüfbar. Zustand A (geprüfte Signatur) kommt mit
* Phase 2.
*
* @return array{key: string, title: string, text: string, tone: string}|null
*/
private function provenanceState(): ?array
{
if ($this->provenanceStatus === null) {
return null;
}
return match (ImageProvenanceStatus::from($this->provenanceStatus)) {
ImageProvenanceStatus::Error => [
'key' => 'error',
'title' => __('Herkunftsdaten konnten nicht geprüft werden.'),
'text' => __('Bitte machen Sie die Angaben selbst. Ihr Upload wird dadurch nicht blockiert.'),
'tone' => 'amber',
],
ImageProvenanceStatus::UnsignedMetadata => [
'key' => 'prefilled',
'title' => __('Angaben aus den Metadaten der Datei vorbelegt.'),
'text' => $this->aiSuggestion !== null
? __('Die Datei nennt als Quelle „:type". Sie können die Auswahl ändern.', ['type' => AiImageDeclaration::from($this->aiSuggestion)->label()])
: ($this->enhancementHint
? __('Die Datei ist als algorithmisch nachbearbeitet gekennzeichnet (z. B. Schärfung). Das allein ist keine KI-Bearbeitung.')
: __('Bitte prüfen Sie den vorgeschlagenen Bildnachweis.')),
'tone' => 'zinc',
],
ImageProvenanceStatus::ValidatedTrusted => [
'key' => 'verified',
'title' => __('Herkunftsdaten erkannt (Content Credentials).'),
'text' => __('Die Kennzeichnung ist fest hinterlegt und kann nicht abgeschwächt werden.'),
'tone' => 'green',
],
default => [
'key' => 'absent',
'title' => __('Keine Herkunftsdaten in der Datei gefunden.'),
'text' => __('Bitte machen Sie die Angaben selbst.'),
'tone' => 'zinc',
],
};
}
/**
* @return array<string, string>
*/
private function focusOptions(): array
{
return [
'0,0' => __('oben links'),
'50,0' => __('oben'),
'100,0' => __('oben rechts'),
'0,50' => __('links'),
'50,50' => __('Mitte'),
'100,50' => __('rechts'),
'0,100' => __('unten links'),
'50,100' => __('unten'),
'100,100' => __('unten rechts'),
];
}
private function agbUrl(): string
{
return route('agb', ['edition' => 'de']);
}
private function companyName(): string
{
return (string) ($this->getPressRelease()->company()->withoutGlobalScope(PortalScope::class)->value('name') ?? '');
}
private function actsAsAdmin(): bool
{
$user = auth()->user();
return $user !== null && $user->canAccessAdmin() && $this->getPressRelease()->user_id !== $user->getKey();
}
private function getPressRelease(): PressRelease
{
return PressRelease::withoutGlobalScope(PortalScope::class)->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()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first();
}
private function resetProvenance(): void
{
$this->provenanceStatus = null;
$this->provenanceSha = null;
$this->creditSuggestion = null;
$this->aiSuggestion = null;
$this->enhancementHint = false;
}
private function resetUploadForm(): void
{
$this->reset(['newImage', 'newCopyright', 'newAiDeclaration', 'newTitle', 'newDescription', 'newLicenseUrl', 'newSourceUrl', 'newRightsConfirmed', 'noCrop', 'focus', 'showLegalDetails', 'autoCopyright']);
$this->resetProvenance();
}
}; ?>
<flux:card>
<div class="flex items-center justify-between">
<flux:heading size="md">{{ __('Titelbild') }}</flux:heading>
<flux:badge color="{{ $titleImage ? ($awaitsCustomerConfirmation ? 'amber' : 'green') : 'zinc' }}" size="sm">
{{ $titleImage ? ($awaitsCustomerConfirmation ? __('wartet auf Kundenbestätigung') : __('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->copyright)
<p class="m-0 truncate text-xs text-zinc-500" title="{{ $titleImage->copyright }}">
{{ __('Bildnachweis: :copyright', ['copyright' => $titleImage->copyright]) }}
</p>
@elseif ($titleImage->author)
<p class="m-0 truncate text-xs text-zinc-500">© {{ $titleImage->author }}</p>
@endif
<div class="flex flex-wrap items-center gap-1 text-xs text-zinc-400">
@if ($aiBadge = $titleImage->ai_declaration?->publicLabel() ?? ($titleImage->is_ai_generated ? __('KI-generiert') : null))
<flux:badge color="purple" size="xs">{{ $aiBadge }}</flux:badge>
@endif
@if ($titleImage->ai_declaration_source)
<span>{{ __('Kennzeichnung: :source', ['source' => $titleImage->ai_declaration_source->publicLabel()]) }}</span>
@endif
@if ($titleImage->width && $titleImage->height)
<span>{{ $titleImage->width }}×{{ $titleImage->height }}</span>
@endif
</div>
{{-- Bestätigungsstand (D.3): Kennung, AGB-Fassung, Zeitpunkt, Kanal. --}}
@if ($latestConfirmation)
<p class="m-0 text-xs text-zinc-500">
{{ __('Rechte bestätigt am :date (:key, AGB :agb, :channel)', [
'date' => $latestConfirmation->confirmed_at->format('d.m.Y H:i'),
'key' => $latestConfirmation->legal_text_key,
'agb' => $latestConfirmation->agb_version ?? '–',
'channel' => $latestConfirmation->channel->value,
]) }}
</p>
@elseif ($titleImage->isLegacyRules())
<p class="m-0 text-xs text-zinc-400">{{ __('Bestand ohne Bestätigungsprotokoll.') }}</p>
@endif
</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>
@if ($awaitsCustomerConfirmation)
<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">
{{ __('Dieses Bild wurde im Auftrag hochgeladen. Es wird erst veröffentlicht, wenn der Kontoinhaber die Bildrechte bestätigt hat.') }}
@if ($isAdminActor)
<flux:button size="xs" variant="ghost" class="ml-2" wire:click="resendConfirmationRequest">
{{ __('Bestätigungslink erneut senden') }}
</flux:button>
@endif
</div>
@endif
</div>
</div>
@elseif($canEdit)
<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>
<flux:modal wire:model.self="isUploadFormOpen" :dismissible="false" class="w-full max-w-3xl">
@if ($isUploadFormOpen)
<form wire:submit="saveImage" class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Titelbild hochladen') }}</flux:heading>
<flux:subheading>
{{ __('Bild auswählen, Bildnachweis und KI-Kennzeichnung angeben, bestätigen — fertig.') }}
</flux:subheading>
</div>
{{-- ===== 1 · Bild ===== --}}
<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') }}</span>
</div>
@if (!$newImage)
<flux:file-upload wire:model="newImage" accept="image/jpeg,image/png,image/webp"
:description="__('JPG/PNG/WebP, max. :mb MB. Wird als Titelbild gespeichert und ersetzt den Platzhalter.', ['mb' => $maxMb])">
<flux:file-upload.dropzone :heading="__('Bild hierher ziehen oder klicken')"
:text="__('JPG, PNG oder WebP · max. :mb MB', ['mb' => $maxMb])" 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
<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 {{ $noCrop ? 'object-contain' : 'object-cover' }}"
style="object-position: {{ str_replace(',', '% ', $focus) }}%" />
@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>
@if ($provenanceState)
<div class="rounded-md border p-3 text-xs leading-relaxed
{{ $provenanceState['tone'] === 'amber' ? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100' : ($provenanceState['tone'] === 'green' ? 'border-green-200 bg-green-50 text-green-900 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-100' : 'border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300') }}"
data-provenance="{{ $provenanceState['key'] }}">
<span class="font-semibold">{{ $provenanceState['title'] }}</span>
{{ $provenanceState['text'] }}
@if ($provenanceState['key'] !== 'absent')
<span class="block mt-1 text-zinc-500">
{{ __('Stimmen die Herkunftsdaten nicht? Schreiben Sie uns an :email.', ['email' => config('legal.contact_email')]) }}
</span>
@endif
</div>
@endif
<div class="grid gap-3 sm:grid-cols-2">
<flux:select wire:model.live="focus" :label="__('Bildausschnitt: wichtiger Bereich')"
:description="__('Der Zuschnitt für Vorschauen hält diesen Bereich im Bild.')">
@foreach ($focusOptions as $value => $label)
<flux:select.option :value="$value">{{ $label }}</flux:select.option>
@endforeach
</flux:select>
<flux:switch wire:model.live="noCrop" align="right" :label="__('Nicht beschneiden')"
:description="__('Das ganze Bild bleibt sichtbar, mit neutralem Rand.')" />
</div>
@endif
<flux:error name="newImage" />
</section>
@if ($newImage)
{{-- ===== 2 · Angaben ===== --}}
<section class="space-y-4 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">{{ __('Bildnachweis & KI-Kennzeichnung') }}</span>
</div>
<flux:input wire:model="newCopyright" :label="__('Bildnachweis')" :badge="__('Pflicht')"
:description="$creditSuggestion !== null
? __('Vorschlag aus den Metadaten der Datei — bitte prüfen. So erscheint der Nachweis unter dem Bild („Foto: …").')
: __('Vorschlag: Ihr Firmenname — bitte prüfen. Bei Stock- oder Agenturbildern den Nachweis so eintragen, wie die Lizenz ihn verlangt.')"
:placeholder="$companyName ?: __('z. B. Max Mustermann / Beispiel GmbH')" required />
<flux:radio.group wire:model="newAiDeclaration" :label="__('KI-Kennzeichnung')" :badge="__('Pflicht')"
:description="__('KI-generierte oder KI-bearbeitete Bilder erhalten öffentlich einen neutralen Hinweis. Übliche Bearbeitungen wie Zuschnitt, Farbkorrektur oder Schärfung gelten nicht als KI-Bearbeitung.')"
required>
@foreach ($aiOptions as $aiImageOption)
<flux:radio :value="$aiImageOption->value" :label="$aiImageOption->label()" />
@endforeach
</flux:radio.group>
<flux:error name="newAiDeclaration" />
<details class="group" @if ($newLicenseUrl || $newSourceUrl || $newTitle || $newDescription) open @endif>
<summary class="flex cursor-pointer list-none items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-500 [&::-webkit-details-marker]:hidden">
<flux:icon.chevron-right class="size-3.5 transition-transform group-open:rotate-90" />
{{ __('Optionale Angaben') }}
<span class="font-normal normal-case tracking-normal text-zinc-400">— {{ __('Titel, Bildunterschrift, Lizenz, Quelle') }}</span>
</summary>
<div class="mt-3 space-y-3">
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="newTitle" :label="__('Titel / Alt-Text')"
:description="__('Beschreibt das Bild für Suchmaschinen und Screenreader.')" />
<flux:input wire:model="newDescription" :label="__('Bildunterschrift')" />
</div>
<div class="grid gap-3 sm:grid-cols-2">
<flux:input wire:model="newLicenseUrl" type="url" :label="__('Lizenz (Link)')"
:description="__('Nur, wenn eine Lizenz die Nutzung regelt, z. B. Creative Commons oder Agentur.')" placeholder="https://…" />
<flux:input wire:model="newSourceUrl" type="url" :label="__('Quelle (Link)')"
:description="__('Fundstelle oder Ursprungsseite — keine Lizenz.')" placeholder="https://…" />
</div>
</div>
</details>
</section>
{{-- ===== 3 · 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">3</span>
<span class="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{{ __('Bestätigung') }}</span>
</div>
@if ($isAdminActor)
<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">
{{ __('Sie laden im Auftrag des Kunden hoch. Die Rechtebestätigung gibt der Kontoinhaber selbst ab: Er erhält nach dem Upload einen Link „Bild bestätigen". Bis dahin bleibt der Platzhalter aktiv.') }}
</div>
@else
<flux:switch wire:model="newRightsConfirmed" align="right" :label="__('Ich bestätige')"
:description="$legalShort" />
<flux:error name="newRightsConfirmed" />
<button type="button" wire:click="$toggle('showLegalDetails')"
class="flex items-center gap-1 text-xs text-zinc-500 underline-offset-2 hover:underline"
aria-expanded="{{ $showLegalDetails ? 'true' : 'false' }}">
<flux:icon.chevron-right class="size-3.5 transition-transform {{ $showLegalDetails ? 'rotate-90' : '' }}" />
{{ __('Vollständiger Wortlaut') }}
@if ($legalTextKey)
<span class="text-zinc-400">({{ $legalTextKey }})</span>
@endif
</button>
@if ($showLegalDetails && $legalLong)
<blockquote class="m-0 rounded-md border border-zinc-200 bg-zinc-50 p-3 text-xs leading-relaxed text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300" data-legal-text>
{{ $legalLong }}
</blockquote>
@endif
@endif
</section>
@endif
<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>
@if ($newImage)
<flux:button type="submit" variant="primary" icon="arrow-up-tray">
{{ __('Hochladen & einsetzen') }}</flux:button>
@endif
</div>
</form>
@endif
</flux:modal>
@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>

View file

@ -700,7 +700,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
@endif
{{-- 7) MEDIEN — Image-Manager direkt eingebunden --}}
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
@if (config('images.rights_v2'))
<livewire:components.press-release-images-manager-v2 :press-release-id="$id" :wire:key="'pr-images-v2-'.$id" />
@else
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
@endif
{{-- 7) ANHÄNGE — TEMPORÄR DEAKTIVIERT
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung

View file

@ -17,6 +17,9 @@ use App\Services\PressRelease\AiDeclarationRequiredException;
use App\Services\PressRelease\BlacklistViolationException;
use App\Services\PressRelease\BookingRequiredException;
use App\Services\PressRelease\BoostService;
use App\Services\Image\ImageRightsTextRenderer;
use App\Services\PressRelease\Images\CustomerImageConfirmation;
use App\Services\PressRelease\Images\ImageUploadException;
use App\Services\PressRelease\PressReleaseCoverImage;
use App\Services\PressRelease\PressReleaseService;
use App\Services\PressRelease\QuotaExceededException;
@ -105,6 +108,48 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
*/
public bool $purchaseConfirmed = false;
/**
* „Bild bestätigen" (Bildkennzeichnung B.4, Decision-Update E10): Schalter
* für die Rechtebestätigung eines im Auftrag hochgeladenen Titelbilds.
*/
public bool $imageRightsConfirmed = false;
public function confirmImageRights(CustomerImageConfirmation $confirmation, ImageRightsTextRenderer $texts): void
{
$pr = $this->getMyPR();
$this->authorize('update', $pr);
$image = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first();
if ($image === null || ! $image->awaitsCustomerConfirmation()) {
return;
}
$this->validate(
['imageRightsConfirmed' => ['accepted']],
['imageRightsConfirmed.accepted' => __('Die Bestätigung ist erforderlich, damit das Bild veröffentlicht werden kann.')],
);
try {
$confirmation->confirm(
$image,
auth()->user(),
$texts->current()?->key,
request()->ip(),
request()->userAgent(),
route('agb', ['edition' => 'de']),
);
} catch (ImageUploadException $exception) {
$this->addError('imageRightsConfirmed', $exception->getMessage());
return;
}
$this->imageRightsConfirmed = false;
Flux::toast(text: __('Bildrechte bestätigt. Das Titelbild kann jetzt veröffentlicht werden.'), variant: 'success');
}
public function mount(int $id): void
{
$this->id = $id;
@ -822,7 +867,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
'categoryName' => $categoryName,
'coverUrl' => $cover->coverUrl($pr, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
'titleImage' => $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(),
'titleImage' => $titleImage = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(),
'awaitsImageConfirmation' => $titleImage?->awaitsCustomerConfirmation() === true && auth()->user()->can('update', $pr),
'imageLegalShort' => ($imageLegalText = app(ImageRightsTextRenderer::class)->current())
? app(ImageRightsTextRenderer::class)->renderShort($imageLegalText, route('agb', ['edition' => 'de']))
: null,
'imageLegalLong' => $imageLegalText
? app(ImageRightsTextRenderer::class)->renderLong($imageLegalText, route('agb', ['edition' => 'de']))
: null,
'quotaTotal' => $user->pressReleaseQuotaTotal(),
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
'canEdit' => auth()->user()->can('update', $pr)
@ -1710,6 +1762,40 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
@endif
</article>
{{-- ============== BILD BESTÄTIGEN (Admin-Upload, E10) ============== --}}
@if ($awaitsImageConfirmation)
<article class="panel" data-image-confirmation>
<div class="panel-head">
<span class="section-eyebrow">{{ __('Titelbild bestätigen') }}</span>
</div>
<div class="p-5 space-y-4 max-w-[760px]">
<p class="m-0 text-[13.5px] leading-relaxed">
{{ __('Dieses Titelbild wurde von unserem Team für Sie hinterlegt. Es wird erst veröffentlicht, wenn Sie bestätigen, dass Sie zur Veröffentlichung berechtigt sind und die Angaben stimmen.') }}
</p>
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-[12.5px] m-0">
<dt class="text-[color:var(--color-ink-3)]">{{ __('Bildnachweis') }}</dt>
<dd class="m-0">{{ $titleImage->copyright ?: '—' }}</dd>
<dt class="text-[color:var(--color-ink-3)]">{{ __('KI-Kennzeichnung') }}</dt>
<dd class="m-0">{{ $titleImage->ai_declaration?->label() ?? '—' }}</dd>
</dl>
<flux:switch wire:model="imageRightsConfirmed" align="right" :label="__('Ich bestätige')" :description="$imageLegalShort" />
<flux:error name="imageRightsConfirmed" />
@if ($imageLegalLong)
<details class="group">
<summary class="flex cursor-pointer list-none items-center gap-1 text-[12px] text-[color:var(--color-ink-3)] [&::-webkit-details-marker]:hidden">
<flux:icon.chevron-right class="size-3.5 transition-transform group-open:rotate-90" />
{{ __('Vollständiger Wortlaut') }}
</summary>
<blockquote class="m-0 mt-2 rounded-md border border-[color:var(--color-bg-rule)] p-3 text-[12px] leading-relaxed text-[color:var(--color-ink-3)]">{{ $imageLegalLong }}</blockquote>
</details>
@endif
<div>
<flux:button variant="primary" wire:click="confirmImageRights">{{ __('Bildrechte bestätigen') }}</flux:button>
</div>
</div>
</article>
@endif
{{-- ============== INHALT ============== --}}
<article class="panel">
<div class="panel-head">