web/admin/pdf: gemeinsame Bildnachweis-Auflösung, Detailfenster am Artikelbild, Bestätigungsstand in Kunden- und Adminansicht, Wortwahl „automatisierte Prüfung" (Bildkennzeichnung Block D)

- App\Support\ImageCredit: Foto/Quelle-Zeile, KI-Hinweis mit Herkunft, Verantwortlich-Zeile nur mit Protokoll, sichere Links; PDF-Zeilen mit Bestätigungskennung
- Artikelseite: Overlay (eigenes i-Zeichen, Alpine ohne Plugin, Bottom-Sheet auf Mobil) mit Nachweis, Lizenz, Quelle, KI-Hinweis, Verantwortlich-Zeile, „Bild melden"
- Richtlinien 0.2: Prüfung als automatisiert beschrieben, Abschnitt „Bilder" (Decision-Update 3.5); Taglines, Suche, Newsrooms, Startseiten-CTA, Mails ohne „qualitätsgeprüft"
- Cache-Invalidierung bei Bildwechsel an veröffentlichten Meldungen; PHPStan-Baseline für das Resource bereinigt

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 09:44:34 +00:00
parent 24184f1dec
commit 9f44546082
26 changed files with 578 additions and 199 deletions

159
app/Support/ImageCredit.php Normal file
View file

@ -0,0 +1,159 @@
<?php
namespace App\Support;
use App\Enums\AiDeclarationSource;
use App\Models\Company;
use App\Models\ImageRightsConfirmation;
use App\Models\PressReleaseImage;
use Illuminate\Support\Str;
/**
* Eine Auflösung des Bildnachweises für alle Ausgaben (Bildkennzeichnung
* D.1): Artikelseite, Kunden- und Adminansicht, Beleg-PDF, Overlay.
*
* Regeln (Decision-Update 3.4):
* - Nachweiszeile: „Foto: [copyright]", sonst „Foto: [author]", sonst im
* Bestand „Quelle: [Firma]".
* - KI-Zusatz aus der Portalkennzeichnung, Bestand-Fallback `is_ai_generated`.
* - Verantwortlich-Zeile „Bild und Angaben: [Firma], eingereicht am [Datum]"
* NUR mit Bestätigungsprotokoll — der Altbestand hat keins.
* - Links nur mit http/https.
*/
final readonly class ImageCredit
{
private function __construct(
public ?string $credit,
public string $creditLabel,
public ?string $aiLabel,
public ?string $aiSourceLabel,
public ?string $licenseUrl,
public ?string $sourceUrl,
public ?string $responsibleLine,
public ?ImageRightsConfirmation $confirmation,
public bool $isLegacy,
) {}
public static function for(PressReleaseImage $image, ?Company $company = null): self
{
$company ??= $image->pressRelease?->company;
$companyName = $company?->name;
$credit = self::clean($image->copyright) ?? self::clean($image->author);
$creditLabel = __('web.detail.photo');
if ($credit === null && $image->isLegacyRules() && filled($companyName)) {
$credit = $companyName;
$creditLabel = __('web.detail.source');
}
$aiLabel = $image->ai_declaration?->publicLabel() ?? ($image->is_ai_generated ? __('KI-generiert') : null);
$confirmation = $image->relationLoaded('latestRightsConfirmation')
? $image->latestRightsConfirmation
: $image->latestRightsConfirmation()->first();
$responsible = null;
if ($confirmation !== null && filled($companyName)) {
$responsible = __('web.detail.image_responsible', [
'company' => $companyName,
'date' => $confirmation->confirmed_at->locale('de')->isoFormat('D. MMMM YYYY'),
]);
}
return new self(
credit: $credit,
creditLabel: $creditLabel,
aiLabel: $aiLabel,
aiSourceLabel: $aiLabel !== null ? $image->ai_declaration_source?->publicLabel() ?? ($image->isLegacyRules() ? null : AiDeclarationSource::Customer->publicLabel()) : null,
licenseUrl: self::safeUrl($image->license_url),
sourceUrl: self::safeUrl($image->source_url),
responsibleLine: $responsible,
confirmation: $confirmation,
isLegacy: $image->isLegacyRules(),
);
}
/**
* Sichtbare Zeile unter dem Bild, z. B. „Foto: Musterfirma · KI-generiert".
*/
public function line(): ?string
{
if ($this->credit === null && $this->aiLabel === null) {
return null;
}
$parts = [];
if ($this->credit !== null) {
$parts[] = $this->creditLabel.': '.$this->credit;
}
if ($this->aiLabel !== null) {
$parts[] = $this->aiLabel;
}
return implode(' · ', $parts);
}
public function hasDetails(): bool
{
return $this->credit !== null || $this->aiLabel !== null || $this->responsibleLine !== null;
}
/**
* Zeilen für das Beleg-PDF (D.4).
*
* @return list<string>
*/
public function pdfLines(): array
{
$lines = [];
if ($this->credit !== null) {
$lines[] = 'Bildnachweis: '.$this->credit;
}
if ($this->licenseUrl !== null) {
$lines[] = 'Lizenz: '.$this->licenseUrl;
}
if ($this->sourceUrl !== null) {
$lines[] = 'Quelle: '.$this->sourceUrl;
}
if ($this->aiLabel !== null) {
$lines[] = 'KI-Kennzeichnung: '.$this->aiLabel.($this->aiSourceLabel !== null ? ' ('.$this->aiSourceLabel.')' : '');
}
if ($this->confirmation !== null) {
$lines[] = sprintf(
'Rechte bestätigt am %s (%s, AGB %s)',
$this->confirmation->confirmed_at->format('d.m.Y H:i'),
$this->confirmation->legal_text_key,
$this->confirmation->agb_version ?? '–',
);
}
return $lines;
}
private static function clean(?string $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
private static function safeUrl(?string $url): ?string
{
$url = trim((string) $url);
if ($url === '' || ! Str::startsWith(strtolower($url), ['http://', 'https://'])) {
return null;
}
return filter_var($url, FILTER_VALIDATE_URL) !== false ? $url : null;
}
}