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 214aea8910
commit 3d3ddc2dc8
11 changed files with 1380 additions and 5 deletions

View file

@ -0,0 +1,35 @@
<x-emails.layout title="Bitte bestätigen Sie das Titelbild" preheader="Für Ihre Pressemitteilung wurde ein Titelbild hinterlegt. Es wird erst veröffentlicht, wenn Sie die Bildrechte bestätigen.">
<p>{{ filled($recipientName) ? 'Hallo '.$recipientName.',' : 'Guten Tag,' }}</p>
<p>
für Ihre Pressemitteilung wurde von unserem Team ein Titelbild hinterlegt.
Damit es veröffentlicht werden kann, brauchen wir Ihre Bestätigung, dass Sie
zur Veröffentlichung des Bildes berechtigt sind und die Angaben dazu stimmen.
</p>
<p class="title">{{ $pressRelease->title }}</p>
<dl class="meta">
<dt>Bildnachweis</dt>
<dd>{{ $image->copyright ?: '—' }}</dd>
<dt>KI-Kennzeichnung</dt>
<dd>{{ $image->ai_declaration?->label() ?? 'noch offen' }}</dd>
</dl>
<x-emails.button :url="$confirmationUrl">Bild ansehen und bestätigen</x-emails.button>
<p>
Der Link meldet Sie direkt in Ihrem Konto an und ist gültig bis
<strong>{{ $expiresAt->format('d.m.Y H:i') }} Uhr</strong>. Danach können Sie
sich jederzeit regulär anmelden und die Bestätigung auf der Seite der
Pressemitteilung nachholen.
</p>
<div class="note">
<p>
Ohne Ihre Bestätigung bleibt die Pressemitteilung mit dem Platzhalter-Bild.
Wenn das Bild nicht von Ihnen stammt oder die Angaben nicht stimmen,
antworten Sie bitte auf diese E-Mail.
</p>
</div>
</x-emails.layout>

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">