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 */ 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 */ 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 */ 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 */ 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(); } }; ?>
{{ __('Titelbild') }} {{ $titleImage ? ($awaitsCustomerConfirmation ? __('wartet auf Kundenbestätigung') : ($awaitsPrecheckReview ? __('wird kurz gesichtet') : __('gesetzt'))) : __('Platzhalter aktiv') }}
@if ($titleImage)
@php $titleImageUrl = $titleImage->variantUrl('cover') ?? ($titleImage->variantUrl('large') ?? ($titleImage->variantUrl('medium') ?? $titleImage->url())); @endphp @if ($titleImageUrl) {{ $titleImage->title ?? __('Titelbild') }} @endif
{{ $titleImage->title ?: __('Eigenes Titelbild') }}
@if ($titleImage->copyright)

{{ __('Bildnachweis: :copyright', ['copyright' => $titleImage->copyright]) }}

@elseif ($titleImage->author)

© {{ $titleImage->author }}

@endif
@if ($aiBadge = $titleImage->ai_declaration?->publicLabel() ?? ($titleImage->is_ai_generated ? __('KI-generiert') : null)) {{ $aiBadge }} @endif @if ($titleImage->ai_declaration_source) {{ __('Kennzeichnung: :source', ['source' => $titleImage->ai_declaration_source->publicLabel()]) }} @endif @if ($titleImage->width && $titleImage->height) {{ $titleImage->width }}×{{ $titleImage->height }} @endif
{{-- Bestätigungsstand (D.3): Kennung, AGB-Fassung, Zeitpunkt, Kanal. --}} @if ($latestConfirmation)

{{ __('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, ]) }}

@elseif ($titleImage->isLegacyRules())

{{ __('Bestand ohne Bestätigungsprotokoll.') }}

@endif
@if ($canEdit) {{ __('Titelbild löschen') }} @endif
@if ($awaitsPrecheckReview)
{{ __('Wird kurz gesichtet. Ihre Meldung läuft normal weiter; das Bild erscheint, sobald die Sichtung abgeschlossen ist.') }}
@endif @if ($awaitsCustomerConfirmation)
{{ __('Dieses Bild wurde im Auftrag hochgeladen. Es wird erst veröffentlicht, wenn der Kontoinhaber die Bildrechte bestätigt hat.') }} @if ($isAdminActor) {{ __('Bestätigungslink erneut senden') }} @endif
@endif
@elseif($canEdit)
{{ __('Hier fehlt ein Titelbild') }} {{ __('Der Platzhalter bleibt aktiv, bis ein eigenes Titelbild hochgeladen wurde.') }}
{{ __('Eigenes Titelbild hochladen') }}
@if ($isUploadFormOpen)
{{ __('Titelbild hochladen') }} {{ __('Bild auswählen, Bildnachweis und KI-Kennzeichnung angeben, bestätigen — fertig.') }}
{{-- ===== 1 · Bild ===== --}}
1 {{ __('Bild') }}
@if (!$newImage)
{{ __('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.') }}
@else
@if ($this->newImagePreviewUrl()) {{ __('Vorschau des gewählten Titelbilds') }} @else
@endif
{{ $newImage->getClientOriginalName() }} · {{ number_format($newImage->getSize() / 1048576, 2, ',', '.') }} MB
{{ __('Anderes Bild wählen') }}
@if ($provenanceState)
{{ $provenanceState['title'] }} {{ $provenanceState['text'] }} @if ($provenanceState['key'] !== 'absent') {{ __('Stimmen die Herkunftsdaten nicht? Schreiben Sie uns an :email.', ['email' => config('legal.contact_email')]) }} @endif
@endif @foreach ($precheckHints as $precheckHint)
{{ $precheckHint }}
@endforeach
@foreach ($focusOptions as $value => $label) {{ $label }} @endforeach
@endif
@if ($newImage) {{-- ===== 2 · Angaben ===== --}}
2 {{ __('Bildnachweis & KI-Kennzeichnung') }}
@foreach ($aiOptions as $aiImageOption) @endforeach
{{ __('Optionale Angaben') }} — {{ __('Titel, Bildunterschrift, Lizenz, Quelle') }}
{{-- ===== 3 · Bestätigung ===== --}}
3 {{ __('Bestätigung') }}
@if ($isAdminActor)
{{ __('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.') }}
@else @if ($showLegalDetails && $legalLong)
{{ $legalLong }}
@endif @endif
@endif
{{ __('Abbrechen') }} @if ($newImage) {{ __('Hochladen & einsetzen') }} @endif
@endif
@else
{{ __('Noch kein eigenes Titelbild hinterlegt. Der Platzhalter bleibt aktiv.') }}
@endif