presseportale/resources/views/livewire/components/press-release-images-manager-v2.blade.php
Kevin Adametz 8bb7049bdc images: KI-Vorab-Check für Titelbilder — Vision-Sichtung mit definiertem Ausgang je Befund, Admin-Queue, Protokoll und Archiv-Snapshot (Bildkennzeichnung Block K)
Decision-Update Abschnitt 12 (E15, S24): Sichtung auf offensichtliche
Probleme, keine Rechteprüfung, öffentlich weiterhin „automatisierte Prüfung".

- ImagePrecheckManager mit openai- und deterministic-Treiber (Muster der
  Text-Prüfstrecke), Bild auf 1024 px, Metadaten gestrippt, JPEG als Data-URI,
  Antwortschema 12.4; Fehler → Ausgang `error`, durchlassen mit Vermerk.
- Auswertung 12.3: reject (Stock-Wasserzeichen, Nacktheit/Gewalt, Dokument
  ab 0,85), queue (Screenshot, fremdes Logo, Kennzeichen ab 0,6 sowie
  Ablehnungskategorien 0,6–0,85), hint (Personen, „wirkt KI" nur bei „ohne
  KI", technischer Mangel). Stufe 1 wertet nur das Wasserzeichen; die KI setzt
  nie die Kennzeichnung (E9). Herabstufung per Config (12.8).
- Uploader: Check vor jedem Schreiben, Ablehnung speichert nichts und
  landet als ki_audits `image_precheck` an der PM; Hash-Bindung zwischen
  Vorprüfung im Formular und Speichern; Spalten precheck*, Snapshot
  `precheck_snapshot` im Bestätigungsprotokoll, Archiv-Payload erweitert.
- Queue: Bild zurückgehalten (withheld_at), Meldung läuft weiter; Admin
  entscheidet mit Vermerk (Freigabe/Ablehnung → ki_audits
  `image_precheck_review`); Ansicht „Bild-Sichtung" in der PM-Liste,
  Sichtungsblock auf der PM-Seite, Systemstatus-Zähler.
- Formular v2: Ablehnungsgrund unter dem Dateifeld, Hinweise nach der
  Dateiauswahl, „wird kurz gesichtet" an der Karte; API 422 mit Grund,
  Warnung `image_under_review`.
- Config images.precheck (IMAGE_PRECHECK aus, STAGE 1); 17 Tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 13:27:31 +00:00

795 lines
38 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\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\Image\Precheck\ImagePrecheckResult;
use App\Services\Image\Precheck\ImagePrecheckService;
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;
/** Ergebnis des KI-Vorab-Checks (Block K) mit Hash-Bindung; null = Check aus. */
#[Locked]
public ?array $precheck = null;
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'];
// Vorab-Check (K.3): Ablehnung zeigt den Grund unter dem Dateifeld,
// alles andere bleibt als Hinweis oder wird beim Speichern übernommen.
try {
$this->precheck = $uploader->precheck($this->newImage, $this->getPressRelease())?->toArray();
} catch (ImageUploadException $exception) {
$this->addError('newImage', $exception->getMessage());
$this->resetProvenance();
$this->reset('newImage');
return;
}
// 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(),
precheck: $this->precheck !== null ? ImagePrecheckResult::fromArray($this->precheck) : null,
);
} 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,
'awaitsPrecheckReview' => $titleImage?->awaitsPrecheckReview() ?? false,
'precheckHints' => $this->precheckHints(),
'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();
}
/**
* Hinweise aus dem Vorab-Check (DU 12.3), abhängig von der aktuellen
* KI-Angabe: „wirkt KI-generiert" erscheint nur bei „ohne KI".
*
* @return array<int, string>
*/
private function precheckHints(): array
{
if ($this->precheck === null) {
return [];
}
$result = ImagePrecheckResult::fromArray($this->precheck);
if ($result === null) {
return [];
}
$choice = $this->newAiDeclaration !== '' ? AiImageDeclaration::tryFrom($this->newAiDeclaration) : null;
return app(ImagePrecheckService::class)->evaluate($result, $choice)->hints();
}
private function resetProvenance(): void
{
$this->precheck = null;
$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 || $awaitsPrecheckReview) ? 'amber' : 'green') : 'zinc' }}" size="sm">
{{ $titleImage ? ($awaitsCustomerConfirmation ? __('wartet auf Kundenbestätigung') : ($awaitsPrecheckReview ? __('wird kurz gesichtet') : __('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 ($awaitsPrecheckReview)
<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" data-precheck-review>
{{ __('Wird kurz gesichtet. Ihre Meldung läuft normal weiter; das Bild erscheint, sobald die Sichtung abgeschlossen ist.') }}
</div>
@endif
@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
@foreach ($precheckHints as $precheckHint)
<div class="rounded-md border border-sky-200 bg-sky-50 p-3 text-xs leading-relaxed text-sky-900 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-100" data-precheck-hint>
{{ $precheckHint }}
</div>
@endforeach
<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.live="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>