presseportale/resources/views/livewire/components/press-release-images-manager-v2.blade.php
Kevin Adametz 31da9ea8b6
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
livewire: Volt entfernt — Komponenten als Livewire-4-Single-File-Components, Route::livewire(), Livewire::test(); livewire/volt deinstalliert
- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
  component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
  wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)

Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:59:36 +00:00

847 lines
40 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\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;
/**
* Kennung des Bestätigungstexts, den die Seite gerade anzeigt (Audit N06):
* beim Speichern wird sie gegen die aktuelle Fassung geprüft, damit kein
* Kunde einen Wortlaut bestätigt, der inzwischen abgelöst wurde.
*/
#[Locked]
public ?string $legalTextKey = null;
public function mount(int $pressReleaseId, ImageRightsTextRenderer $texts): void
{
$this->pressReleaseId = $pressReleaseId;
$this->legalTextKey = $texts->current()?->key;
}
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 {
// Hash aus der Vorprüfung weiterreichen (OPT-4), nicht neu rechnen.
$this->precheck = $uploader->precheck($this->newImage, $this->getPressRelease(), $result->fileSha256)?->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();
// Versionsbindung (Audit N06): Der Kunde bestätigt nur den Wortlaut,
// den er gesehen hat. Beim Admin-Upload wird nichts bestätigt.
if (!$isAdmin && $legalText?->key !== $this->legalTextKey) {
$this->legalTextKey = $legalText?->key;
$this->newRightsConfirmed = false;
$this->addError('newRightsConfirmed', __('Der Bestätigungstext wurde aktualisiert. Bitte lesen Sie die neue Fassung und bestätigen Sie erneut.'));
return;
}
[$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,
allowedStatuses: $this->allowedStatuses(),
);
} catch (ImageUploadException $exception) {
$this->addError($exception->field === 'rights_confirmed' ? 'newRightsConfirmed' : 'newImage', $exception->getMessage());
return;
} catch (\RuntimeException $exception) {
// Verarbeitungsfehler der Bildbibliothek (z. B. animiertes WebP,
// B-12): Hinweis am Dateifeld statt Serverfehler.
report($exception);
$this->addError('newImage', __('Das Bild konnte nicht verarbeitet werden. Bitte speichern Sie es als JPG oder PNG und laden Sie es erneut hoch.'));
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;
}
try {
// Status-Regel unter der Sperre erneut prüfen (B-05).
$remover->remove($image, auth()->user(), allowedStatuses: $this->allowedStatuses());
} catch (ImageUploadException $exception) {
Flux::toast(text: $exception->getMessage(), variant: 'danger');
return;
}
$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();
$this->legalTextKey = $legalText?->key;
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(),
'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);
}
/**
* Status-Regel des handelnden Nutzers: Kunden ändern das Titelbild nur bei
* Entwürfen und abgelehnten PMs, Admins bei allem außer Archiv. Der
* Uploader prüft dieselbe Liste unter der Sperre erneut (Audit N07).
* Gekoppelt an actsAsAdmin() (B-09): Ein Admin, der selbst Eigentümer
* ist, lädt als Kunde hoch (sofort bestätigt) und bekommt deshalb auch
* die Kundenregel — sonst wäre ein Bild an einer veröffentlichten
* Meldung ohne Admin-Weg sofort öffentlich.
*
* @return list<PressReleaseStatus>
*/
private function allowedStatuses(): array
{
if ($this->actsAsAdmin()) {
return [PressReleaseStatus::Draft, PressReleaseStatus::Review, PressReleaseStatus::Published, PressReleaseStatus::Rejected];
}
return [PressReleaseStatus::Draft, PressReleaseStatus::Rejected];
}
private function canChangeImages(PressRelease $pressRelease): bool
{
return in_array($pressRelease->status, $this->allowedStatuses(), 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>