presseportale/resources/views/livewire/admin/images/index.blade.php
Kevin Adametz f1c0e2766b images: nur freigegebene Bilder in Feed und PDF, versionierte Veröffentlichungssnapshots, Bestätigung an das gezeigte Bild gebunden, getrennte Rückhaltegründe (Umsetzungsaudit 29.09.: B-01 bis B-14, N09, OPT-3 bis OPT-5; 02.10.: O03)
- B-01/B-02: Startseiten-Feed, Card-Helfer und Nachweis-PDF nur mit
  isPubliclyDeliverable()
- B-03: Livewire-Temp-Upload 16 MiB
- N09: Snapshot je Bytes- und Metadaten-Hash inkl. OG-Variante, put()
  geprüft, alle Fassungen ins Archiv
- O03: published_ever_at in der Freigabetransaktion
  (markPublishedIfNowPublic) — Kundenbestätigung, Vorab-Sichtung, Korrektur
- B-04/B-14: Bestätigung an Bild-ID und SHA gebunden, unter Sperre
- B-05/B-06: Entfernen unter Sperre mit frischem Stand und Archivauftrag;
  Korrektur archiviert über den Auftrag, Nacharbeiten abgesichert
- B-07: precheck_withheld_at getrennt von withheld_at (Migration + Übernahme)
- B-08, B-09, B-11, B-12: published_ever_at nur bei Status published,
  Admin-Eigentümer mit Kundenregel, Sweep verwaister Originale,
  Verarbeitungsfehler als Formularmeldung
- K-02-Teil: nach Kundenbestätigung erneut routen
- OPT-3 bis OPT-5: pingImage für JPEG, Hash durchgereicht, erstes Frame

Admin-PM-Detailseite enthält zusätzlich K-06 (Autorisierung) und A-08
(Nachweis per POST), weil die Datei ungeteilt committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:01 +00:00

257 lines
14 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\ImagePrecheckCategory;
use App\Enums\ImagePrecheckOutcome;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Volt\Component;
use Livewire\WithPagination;
/**
* Admin ▸ Management ▸ Bilder (Bildkennzeichnung, Wunsch vom 25.09.2026).
*
* Alle eingereichten Titelbilder mit Link zur Meldung, Nachweis,
* Kennzeichnung, Herkunft, Bestätigungsstand und dem Ergebnis des
* KI-Vorab-Checks (Block K). Entscheidungen fallen weiter auf der PM-Seite.
*/
new #[Layout('components.layouts.app'), Title('Bilder')] class extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $precheck = '';
#[Url]
public string $state = '';
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedPrecheck(): void
{
$this->resetPage();
}
public function updatedState(): void
{
$this->resetPage();
}
public function with(): array
{
$query = PressReleaseImage::query()
->with([
'pressRelease' => fn ($q) => $q->withoutGlobalScope(PortalScope::class)->select(['id', 'title', 'status', 'portal', 'company_id', 'published_at'])->with('company:id,name'),
'latestRightsConfirmation',
'precheckReviewer:id,name',
])
->orderByDesc('id');
if ($this->search !== '') {
$term = trim($this->search);
$query->where(function ($q) use ($term): void {
if (ctype_digit($term)) {
$q->where('id', (int) $term)->orWhere('press_release_id', (int) $term);
}
$q->orWhereHas('pressRelease', fn ($pr) => $pr->withoutGlobalScope(PortalScope::class)->where('title', 'like', '%'.$term.'%'))
->orWhere('copyright', 'like', '%'.$term.'%');
});
}
if ($this->precheck !== '') {
$this->precheck === 'none'
? $query->whereNull('precheck_outcome')
: $query->where('precheck_outcome', $this->precheck);
}
match ($this->state) {
'review' => $query->awaitingPrecheckReview(),
'withheld' => $query->where(fn ($inner) => $inner->whereNotNull('withheld_at')->orWhereNotNull('precheck_withheld_at')),
'unconfirmed' => $query->whereNotNull('rules_version')->whereNull('rights_confirmed_at'),
'legacy' => $query->whereNull('rules_version'),
'published' => $query->whereNotNull('published_ever_at'),
default => null,
};
return [
'images' => $query->paginate(40),
'outcomes' => ImagePrecheckOutcome::cases(),
'openReviews' => PressReleaseImage::query()->awaitingPrecheckReview()->count(),
'precheckEnabled' => (bool) config('images.precheck.enabled'),
];
}
}; ?>
<div class="space-y-6">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<flux:heading size="xl">{{ __('Bilder') }}</flux:heading>
<flux:subheading>{{ __('Alle eingereichten Titelbilder mit Nachweis, Kennzeichnung, Bestätigungsstand und dem Ergebnis des KI-Vorab-Checks. Entscheidungen zur Sichtung und Korrekturen laufen auf der Seite der Meldung.') }}</flux:subheading>
</div>
<div class="flex flex-wrap gap-2">
<flux:select wire:model.live="state" size="sm">
<flux:select.option value="">{{ __('Alle Bilder') }}</flux:select.option>
<flux:select.option value="review">{{ __('Sichtung offen') }}</flux:select.option>
<flux:select.option value="withheld">{{ __('zurückgehalten') }}</flux:select.option>
<flux:select.option value="unconfirmed">{{ __('Bestätigung fehlt') }}</flux:select.option>
<flux:select.option value="published">{{ __('je veröffentlicht') }}</flux:select.option>
<flux:select.option value="legacy">{{ __('Bestand (ohne Protokoll)') }}</flux:select.option>
</flux:select>
<flux:select wire:model.live="precheck" size="sm">
<flux:select.option value="">{{ __('KI-Check: alle') }}</flux:select.option>
@foreach ($outcomes as $outcome)
<flux:select.option :value="$outcome->value">{{ __('KI-Check: :label', ['label' => $outcome->label()]) }}</flux:select.option>
@endforeach
<flux:select.option value="none">{{ __('KI-Check: nicht geprüft') }}</flux:select.option>
</flux:select>
<flux:input wire:model.live.debounce.400ms="search" size="sm" :placeholder="__('PM-Titel, Nachweis, PM- oder Bild-ID')" class="w-56" />
</div>
</div>
@unless ($precheckEnabled)
<flux:callout variant="warning" icon="information-circle">
{{ __('Der KI-Vorab-Check ist aus (IMAGE_PRECHECK). Neue Bilder werden nicht gesichtet; die Spalte zeigt nur Altbefunde.') }}
</flux:callout>
@endunless
@if ($openReviews > 0)
<flux:callout variant="warning" icon="eye" data-open-reviews>
{{ trans_choice(':count Bild wartet auf eine Sichtung.|:count Bilder warten auf eine Sichtung.', $openReviews, ['count' => $openReviews]) }}
<flux:button size="xs" variant="ghost" wire:click="$set('state', 'review')">{{ __('Anzeigen') }}</flux:button>
</flux:callout>
@endif
<flux:card>
<table class="w-full text-[13px]">
<thead>
<tr class="text-left text-zinc-500">
<th class="py-2 pr-3 font-medium">{{ __('Bild') }}</th>
<th class="py-2 pr-3 font-medium">{{ __('Pressemitteilung') }}</th>
<th class="py-2 pr-3 font-medium">{{ __('Nachweis und Kennzeichnung') }}</th>
<th class="py-2 pr-3 font-medium">{{ __('Bestätigung') }}</th>
<th class="py-2 pr-3 font-medium">{{ __('KI-Vorab-Check') }}</th>
<th class="py-2 font-medium">{{ __('Hochgeladen') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700 align-top">
@forelse ($images as $image)
@php
$thumb = $image->variantUrl('thumb') ?? $image->variantUrl('card') ?? $image->url();
$confirmation = $image->latestRightsConfirmation;
$triggers = $image->precheck['evaluation']['triggers'] ?? [];
@endphp
<tr wire:key="image-{{ $image->id }}" data-image-row="{{ $image->id }}">
<td class="py-2 pr-3">
<div class="flex items-start gap-2">
@if ($thumb)
<img src="{{ $thumb }}" alt="" class="size-14 shrink-0 rounded object-cover bg-zinc-100 dark:bg-zinc-800" loading="lazy">
@else
<div class="size-14 shrink-0 rounded bg-zinc-100 dark:bg-zinc-800"></div>
@endif
<div class="text-xs text-zinc-500">
<div class="font-mono">#{{ $image->id }}</div>
@if ($image->width && $image->height)
<div>{{ $image->width }}×{{ $image->height }}</div>
@endif
@if ($image->isWithheld())
<flux:badge size="sm" color="amber">{{ __('zurückgehalten') }}</flux:badge>
@endif
</div>
</div>
</td>
<td class="py-2 pr-3 max-w-[280px]">
@if ($image->pressRelease)
<a class="font-medium underline underline-offset-2" href="{{ route('admin.press-releases.show', ['id' => $image->press_release_id]) }}" wire:navigate>
{{ \Illuminate\Support\Str::limit($image->pressRelease->title, 70) }}
</a>
<div class="text-xs text-zinc-500">
PM #{{ $image->press_release_id }} · {{ $image->pressRelease->status->label() }}
@if ($image->pressRelease->company)
· {{ $image->pressRelease->company->name }}
@endif
</div>
@else
<span class="text-zinc-500">PM #{{ $image->press_release_id }} ({{ __('gelöscht') }})</span>
@endif
</td>
<td class="py-2 pr-3 max-w-[260px]">
<div>{{ $image->copyright ?: '–' }}</div>
<div class="text-xs text-zinc-500">
@if ($image->ai_declaration)
{{ $image->ai_declaration->label() }}
@if ($image->ai_declaration_source)
({{ $image->ai_declaration_source->publicLabel() }})
@endif
@if ($image->ai_declaration_locked)
· {{ __('gesperrt') }}
@endif
@else
{{ __('keine Kennzeichnung') }}
@endif
</div>
@if ($image->provenance_status)
<div class="text-xs text-zinc-500">{{ __('Herkunft: :status', ['status' => $image->provenance_status->value]) }}</div>
@endif
</td>
<td class="py-2 pr-3 whitespace-nowrap">
@if ($confirmation)
<flux:badge size="sm" color="green">{{ __('bestätigt') }}</flux:badge>
<div class="text-xs text-zinc-500">{{ $confirmation->confirmed_at->format('d.m.Y H:i') }} · {{ $confirmation->legal_text_key }} · {{ $confirmation->channel->value }}</div>
@elseif ($image->awaitsCustomerConfirmation())
<flux:badge size="sm" color="amber">{{ __('wartet auf Kunde') }}</flux:badge>
@elseif ($image->isLegacyRules())
<flux:badge size="sm" color="zinc">{{ __('Bestand') }}</flux:badge>
@else
<flux:badge size="sm" color="red">{{ __('fehlt') }}</flux:badge>
@endif
</td>
<td class="py-2 pr-3 max-w-[300px]">
@if ($image->precheck_outcome)
<flux:badge size="sm" color="{{ match ($image->precheck_outcome) { \App\Enums\ImagePrecheckOutcome::Queue => 'amber', \App\Enums\ImagePrecheckOutcome::Hint => 'sky', \App\Enums\ImagePrecheckOutcome::Error => 'zinc', default => 'green' } }}">
{{ $image->precheck_outcome->label() }}
</flux:badge>
@if ($triggers !== [])
<ul class="mt-1 text-xs text-zinc-500 space-y-0.5">
@foreach ($triggers as $trigger)
<li>{{ ImagePrecheckCategory::tryFrom($trigger['category'] ?? '')?->label() ?? $trigger['category'] }} ({{ number_format((float) ($trigger['confidence'] ?? 0) * 100, 0) }} %)</li>
@endforeach
</ul>
@endif
@if ($image->awaitsPrecheckReview())
<div class="mt-1 text-xs font-medium text-amber-700 dark:text-amber-300">{{ __('Sichtung offen') }}</div>
@elseif ($image->precheck_reviewed_at)
<div class="mt-1 text-xs text-zinc-500">
{{ __('Entschieden :date von :name: :note', ['date' => $image->precheck_reviewed_at->format('d.m.Y H:i'), 'name' => $image->precheckReviewer?->name ?? '–', 'note' => $image->precheck_review_note]) }}
</div>
@endif
@if (! empty($image->precheck['model']))
<div class="mt-1 text-[11px] text-zinc-400">{{ $image->precheck['model'] }}</div>
@endif
@else
<span class="text-xs text-zinc-500">{{ __('nicht geprüft') }}</span>
@endif
</td>
<td class="py-2 whitespace-nowrap text-xs text-zinc-500">
{{ $image->created_at?->format('d.m.Y H:i') }}
@if ($image->published_ever_at)
<div>{{ __('veröffentlicht') }}</div>
@endif
</td>
</tr>
@empty
<tr><td colspan="6" class="py-6 text-center text-zinc-500">{{ __('Keine Bilder gefunden.') }}</td></tr>
@endforelse
</tbody>
</table>
<div class="mt-4">{{ $images->links() }}</div>
</flux:card>
</div>