legal/web: Zustimmungsgate je Dokumentversion (aus bis AGB 0.7), OG-Variante mit eingebranntem Bildnachweis, Nachweis in Teasern (Bildkennzeichnung Blöcke G und H)

- legal_document_acceptances (insert-only), LegalAcceptanceGate, Pflicht-Schalter im Einreichungs-Modal über den gemeinsamen Trait; API: GET/POST /api/v1/legal/acceptances, submit antwortet 428 mit den geforderten Fassungen; LEGAL_ACCEPTANCE_GATE
- ImageService: og-Variante (1200×630) mit halbtransparentem Balken und Inter SemiBold, regenerateOgVariant() für Korrekturen und Bestand; Fokuspunkt/Letterbox getestet
- Uploader brennt den Nachweis beim Upload ein, ImageCorrectionService baut die OG-Variante nach einer Korrektur neu; Artikelseite nutzt og für og:image
- Teaser: Nachweis in alt/title (Feed, Hero, Kategorie, Newsroom), Unterschrift am Hero-Bild; PressRelease::cardImageCredit()

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 10:12:17 +00:00
parent 0240c143c8
commit 62723b551a
27 changed files with 866 additions and 15 deletions

View file

@ -33,6 +33,11 @@
{{-- Blockform, nicht Inline-Form: die Inline-Variante würde bis zum Ende des nächsten Blocks als Rohblock gelesen (BladeCompiler::storePhpBlocks, Guard: BladePhpBlockGuardTest). --}}
@php
$bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true);
// Zustimmungsgate (Bildkennzeichnung Block G): offene Fassungen von AGB
// und Richtlinien, die vor dem Einreichen aktiv angenommen werden müssen.
$legalGate = app(\App\Services\Legal\LegalAcceptanceGate::class);
$legalDocumentsToAccept = $legalGate->requiredFor(auth()->user())->map(fn ($document) => $legalGate->describe($document))->values();
$needsLegalAcceptance = $legalDocumentsToAccept->isNotEmpty();
@endphp
<flux:modal :name="$name" class="w-full max-w-xl">
@ -72,7 +77,7 @@
@else
{{-- Ohne eigenes Titelbild startet `images` auf true: die Checkbox ist
ausgeblendet und darf den Bestätigen-Button nicht blockieren. --}}
<div class="space-y-5" x-data="{ agb: false, images: {{ $hasTitleImage ? 'false' : 'true' }}, contact: false, waiver: false, ai: false }">
<div class="space-y-5" x-data="{ agb: false, images: {{ $hasTitleImage ? 'false' : 'true' }}, contact: false, waiver: false, ai: false, legal: {{ $needsLegalAcceptance ? 'false' : 'true' }} }">
<div>
<flux:text class="eyebrow muted">{{ __('Veröffentlichung') }}</flux:text>
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung freigeben') }}</flux:heading>
@ -130,6 +135,28 @@
</article>
@endif
@if ($needsLegalAcceptance)
{{-- Zustimmungsgate (Block G): aktiver Klick je Fassung, kein
„Weiternutzung gilt als Zustimmung". Wire-gebunden, weil die
Annahme protokolliert wird (legal_document_acceptances). --}}
<article class="panel overflow-hidden" data-legal-acceptance>
<div class="panel-head" style="padding:10px 16px;">
<span class="section-eyebrow">{{ __('Neue Fassung unserer Bedingungen') }} <span class="text-[color:var(--color-err)]">*</span></span>
</div>
<div class="p-4 space-y-3 text-[12.5px] leading-[1.6] text-[color:var(--color-ink-2)]">
<p class="m-0">{{ __('Vor dem Einreichen nehmen Sie bitte die aktuelle Fassung an:') }}</p>
<ul class="m-0 list-disc space-y-1 ps-5">
@foreach ($legalDocumentsToAccept as $legalDocument)
<li><a href="{{ $legalDocument['url'] }}" target="_blank" rel="noopener" class="underline underline-offset-2">{{ $legalDocument['label'] }}</a> — {{ __('Version :version', ['version' => $legalDocument['version']]) }}</li>
@endforeach
</ul>
<flux:switch align="left" wire:model="legalAcceptanceConfirmed" x-on:change="legal = $event.target.checked"
:label="__('Ich habe die genannten Fassungen gelesen und nehme sie an.')" />
<flux:error name="legalAcceptanceConfirmed" />
</div>
</article>
@endif
{{-- KI-Kennzeichnung (Art. 50 KI-VO, Decision-Update 08.07.2026):
Pflicht-Selbstdeklaration in jedem Veröffentlichungsprozess.
Die Eltern-Komponente stellt die Properties über den Trait
@ -223,7 +250,7 @@
</flux:modal.close>
<flux:button variant="primary" wire:click="{{ $action }}"
wire:loading.attr="disabled"
x-bind:disabled="! (agb && images && contact && waiver && ai)">
x-bind:disabled="! (agb && images && contact && waiver && ai && legal)">
{{ $confirmLabel ?? __('Zur Prüfung freigeben') }}
</flux:button>
</div>

View file

@ -9,6 +9,7 @@
$teaser = $release?->teaserText(180) ?? '';
$company = $release?->company?->name;
$title = $release->title;
$imageCredit = $imageUrl ? $release?->cardImageCredit() : null;
$href = route('release.detail', ['slug' => $release->slug]);
$time = $release?->compactPublishedLabel();
$city = $release?->company?->city ?? null;
@ -18,7 +19,7 @@
<article class="py-6 border-b border-bg-rule grid gap-6 grid-cols-1 md:grid-cols-[240px_1fr] group">
<a href="{{ $href }}" class="relative overflow-hidden bg-feature-grad block cursor-pointer" style="width:100%; max-width:240px; height:160px;">
@if ($imageUrl)
<img src="{{ $imageUrl }}" alt="{{ $title }}" class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
<img src="{{ $imageUrl }}" alt="{{ $imageCredit ? $title.' · '.$imageCredit : $title }}" @if ($imageCredit) title="{{ $imageCredit }}" @endif class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
@elseif ($placeholderUrl)
<img src="{{ $placeholderUrl }}" alt="" aria-hidden="true" class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
@else

View file

@ -18,6 +18,7 @@
$leadHref = route('release.detail', ['slug' => $leadRelease->slug]);
$leadReadTime = $leadRelease->readTimeMinutes();
$leadPublishedAt = $leadRelease->published_at;
$leadImageCredit = $leadImageUrl ? $leadRelease->cardImageCredit() : null;
}
@endphp
@ -37,10 +38,13 @@
<a href="{{ $leadHref }}" class="block group cursor-pointer">
<div class="relative overflow-hidden bg-hero-grad min-h-[320px] sm:min-h-[420px] lg:min-h-[500px] flex flex-col justify-end">
@if ($leadImageUrl)
<img src="{{ $leadImageUrl }}" alt="{{ $leadTitle }}"
<img src="{{ $leadImageUrl }}" alt="{{ $leadImageCredit ? $leadTitle.' · '.$leadImageCredit : $leadTitle }}" @if ($leadImageCredit) title="{{ $leadImageCredit }}" @endif
class="absolute inset-0 h-full w-full object-cover opacity-90 transition-opacity group-hover:opacity-100"
loading="eager">
<div class="absolute inset-0 bg-black/10"></div>
@if ($leadImageCredit)
<span class="absolute top-2 right-3 z-10 text-[10.5px] text-white/85 bg-black/35 px-1.5 py-0.5" data-image-credit-teaser>{{ $leadImageCredit }}</span>
@endif
@elseif ($leadPlaceholderUrl)
<img src="{{ $leadPlaceholderUrl }}" alt="" aria-hidden="true"
class="absolute inset-0 h-full w-full object-cover" loading="eager">

View file

@ -12,6 +12,7 @@
$company = $release?->company?->name;
$href = route('release.detail', ['slug' => $release->slug]);
$imageUrl = $release?->cardImageUrl();
$imageCredit = $imageUrl ? $release?->cardImageCredit() : null;
$placeholderUrl = $imageUrl ? null : $release?->chosenPlaceholderUrl();
$gradient = 'linear-gradient(135deg,#3A4A5A,#6B7C95)';
$number = str_pad((string) $index, 2, '0', STR_PAD_LEFT);
@ -30,7 +31,7 @@
<div class="w-16 h-16 flex-shrink-0 relative overflow-hidden" style="background: {{ $gradient }};">
@if ($imageUrl)
<img src="{{ $imageUrl }}" alt="" class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
<img src="{{ $imageUrl }}" alt="" @if ($imageCredit) title="{{ $imageCredit }}" @endif class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
@elseif ($placeholderUrl)
<img src="{{ $placeholderUrl }}" alt="" aria-hidden="true" class="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy">
@else

View file

@ -310,6 +310,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
// zuerst prüfen, damit die Fehler direkt im Submit-Modal erscheinen.
if ($submitStatus === 'review') {
$this->validateAiDeclaration();
$this->persistLegalAcceptances();
}
$pr = $this->persistDraft();

View file

@ -300,6 +300,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
// zuerst prüfen, damit die Fehler direkt im Submit-Modal erscheinen.
if ($submitAfterSave) {
$this->validateAiDeclaration();
$this->persistLegalAcceptances();
}
$result = $this->persistEdit();

View file

@ -94,6 +94,7 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
// KI-Kennzeichnung (Art. 50 KI-VO): Pflicht-Deklaration aus dem
// Submit-Modal validieren und vor dem Einreichen persistieren.
$this->validateAiDeclaration();
$this->persistLegalAcceptances();
$pr->update($this->aiDeclarationAttributes());
try {

View file

@ -170,6 +170,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
// KI-Kennzeichnung (Art. 50 KI-VO): Pflicht-Deklaration aus dem
// Submit-Modal validieren und vor dem Einreichen persistieren.
$this->validateAiDeclaration();
$this->persistLegalAcceptances();
$pr->update($this->aiDeclarationAttributes());
try {

View file

@ -171,7 +171,13 @@
Text im Flow (min-h + flex), damit lange Titel auf Mobile nicht geclippt werden. --}}
<div class="relative overflow-hidden bg-hero-grad min-h-[240px] sm:min-h-[300px] lg:min-h-[360px] flex flex-col justify-end">
@if ($leadImageUrl)
<img src="{{ $leadImageUrl }}" alt="{{ $leadRelease->title }}" class="absolute inset-0 w-full h-full object-cover" loading="lazy">
@php
$leadImageCredit = $leadRelease->cardImageCredit();
@endphp
<img src="{{ $leadImageUrl }}" alt="{{ $leadImageCredit ? $leadRelease->title.' · '.$leadImageCredit : $leadRelease->title }}" @if ($leadImageCredit) title="{{ $leadImageCredit }}" @endif class="absolute inset-0 w-full h-full object-cover" loading="lazy">
@if ($leadImageCredit)
<span class="absolute top-2 right-3 z-10 text-[10.5px] text-white/85 bg-black/35 px-1.5 py-0.5" data-image-credit-teaser>{{ $leadImageCredit }}</span>
@endif
@elseif ($leadPlaceholderUrl)
<img src="{{ $leadPlaceholderUrl }}" alt="" aria-hidden="true" class="absolute inset-0 w-full h-full object-cover" loading="lazy">
@else

View file

@ -192,7 +192,13 @@
Text im Flow (min-h + flex), damit lange Titel auf Mobile nicht geclippt werden. --}}
<div class="relative overflow-hidden bg-hero-grad min-h-[220px] sm:min-h-[280px] lg:min-h-[340px] flex flex-col justify-end">
@if ($leadImageUrl)
<img src="{{ $leadImageUrl }}" alt="{{ $leadRelease->title }}" class="absolute inset-0 w-full h-full object-cover" loading="lazy">
@php
$leadImageCredit = $leadRelease->cardImageCredit();
@endphp
<img src="{{ $leadImageUrl }}" alt="{{ $leadImageCredit ? $leadRelease->title.' · '.$leadImageCredit : $leadRelease->title }}" @if ($leadImageCredit) title="{{ $leadImageCredit }}" @endif class="absolute inset-0 w-full h-full object-cover" loading="lazy">
@if ($leadImageCredit)
<span class="absolute top-2 right-3 z-10 text-[10.5px] text-white/85 bg-black/35 px-1.5 py-0.5" data-image-credit-teaser>{{ $leadImageCredit }}</span>
@endif
@elseif ($leadPlaceholderUrl)
<img src="{{ $leadPlaceholderUrl }}" alt="" aria-hidden="true" class="absolute inset-0 w-full h-full object-cover" loading="lazy">
@else

View file

@ -169,7 +169,7 @@
@section('meta_description', filled($lead) ? $lead : $teaser)
@section('og_type', 'article')
@if ($imageUrl)
@section('og_image', $imageUrl)
@section('og_image', $image?->variantUrl('og', $signMedia) ?? $imageUrl)
@endif
@if ($isPreview)
@section('robots', 'noindex,nofollow')