Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben F04: withoutGlobalScopes() ohne Argument entfernte neben dem PortalScope auch den Soft-Delete-Filter. Öffentliche, Kunden-, API-, Vorschau- und Medienpfade sowie die PressRelease-Helfer schreiben jetzt withoutGlobalScope(PortalScope::class). Zwei Stellen, die der Audit nicht kannte: die öffentliche Meldungsseite zeigte gelöschte Firmenkontakte mit Name, Mail und Telefon; das Pressefach listete gelöschte Kontakte weiter und ließ sie bearbeiten. Ein Architekturtest friert die 39 bewusst verbliebenen Stellen (Admin, Importer, Befehle, Slug-Eindeutigkeit) ein. Der Vorschau-Test prüfte bisher forceDelete statt delete. F22: LOWER(email) machte auf companies (110k Zeilen) bei jedem Login einen Vollscan; die Kollation ist ohnehin case-insensitiv. EmailQuery vergleicht auf MySQL direkt über den Index, auf SQLite (Tests) weiter per LOWER. Neuer Index contacts_email_idx. F13: Firmen-Observer verwirft die öffentlichen Seiten-Caches bei Löschung, Wiederherstellung und is_active-Wechsel auf jedem Weg; die Newsroom-Seite prüft Sichtbarkeit vor dem Cache-Treffer; Kontakt- Änderungen im Pressefach flushen. Der Tombstone-Test wärmte den Cache vorher nicht und traf den Fehler nie. F12: Gemeldete URLs werden über Host, Ausgabe und Slug aufgelöst (ReportedUrlResolver); fremde Hosts zählen nicht, bei Doppelveröffentlichung gewinnt die Erstquelle. F11: per_page 1–100, status als Enum, language nur de/en, Kategorie muss aktiv sein und zum Ziel-Portal passen. Legacy-Aliasse behalten ihren Vertrag. F23: Rückgabewert von put() wird geprüft; Logo erst speichern, dann Datenbank, dann altes Logo löschen. Fehler enden als Feldfehler, nicht als verlorenes Logo. Block 4 — KI-Prüfung und Scheduler F07: Klassifikation und Score tragen die Prüfsumme des Inhalts bei Einplanung. Weicht sie beim Speichern ab, wird das Ergebnis verworfen (ki_audits.discarded_at) und ein neuer Lauf eingeplant. Der Scheduler veröffentlicht keine Meldung, deren jüngste Klassifikation nicht zum aktuellen Text passt, sondern plant eine Prüfung mit Routing ein. F18: Kontingent an den Kalendermonat gebunden (users.press_release_quota_period). Verbrauch aus einem früheren Monat zählt null, auch ohne Reset-Cron; der Reset ist idempotent und lässt den laufenden Monat in Ruhe. F20: Der Publikations-Scheduler liefert bei technischen Fehlern einen Fehler-Exit-Code, damit der Failure-Hook greift. F17: KI-Jobs mit $timeout = 120, retry_after 150; Runbook ergänzt. F19: Aggregation der Aufrufe unter einem Cache-Lock für alle Einstiege. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
733 lines
37 KiB
PHP
733 lines
37 KiB
PHP
<?php
|
||
|
||
use App\Enums\AiImageDeclaration;
|
||
use App\Enums\ImageLicenseType;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Models\PressRelease;
|
||
use App\Models\PressReleaseImage;
|
||
use App\Services\Image\ImageService;
|
||
use App\Scopes\PortalScope;
|
||
use Flux\Flux;
|
||
use Illuminate\Validation\Rule;
|
||
use Livewire\Attributes\Locked;
|
||
use Livewire\Volt\Component;
|
||
use Livewire\WithFileUploads;
|
||
|
||
/**
|
||
* Reusable image manager for a single press release. Used by both the
|
||
* admin and customer PR edit pages. Authorisation is delegated to the
|
||
* `update` ability on `PressReleasePolicy`, so the same component is safe
|
||
* to use for admins (who can always edit) and customers (only their own).
|
||
*/
|
||
new class extends Component {
|
||
use WithFileUploads;
|
||
|
||
#[Locked]
|
||
public int $pressReleaseId;
|
||
|
||
public $newImage = null;
|
||
|
||
public string $newTitle = '';
|
||
|
||
public string $newCopyright = '';
|
||
|
||
public string $newAuthor = '';
|
||
|
||
public string $newLicenseType = '';
|
||
|
||
public string $newLicenseDetail = '';
|
||
|
||
public string $newLicenseUrl = '';
|
||
|
||
public string $newSourceUrl = '';
|
||
|
||
public string $newPeopleRightsStatus = '';
|
||
|
||
public string $newPropertyRightsStatus = '';
|
||
|
||
public string $newRightsNotes = '';
|
||
|
||
public bool $newRightsConfirmed = false;
|
||
|
||
public bool $newAiTermsConfirmed = false;
|
||
|
||
/**
|
||
* KI-Kennzeichnung je Bild (Art. 50 KI-VO): Originalfoto, KI-generiert
|
||
* oder KI-bearbeitet. Pflichtauswahl — ohne sie kein Upload und ohne
|
||
* vollständige Bild-Kennzeichnung kein Submit (PressReleaseService).
|
||
*/
|
||
public string $newAiDeclaration = '';
|
||
|
||
/**
|
||
* Zuletzt automatisch vorgeschlagener Bildnachweis — nur ein Nachweis,
|
||
* der noch diesem Vorschlag entspricht, wird bei Änderungen erneut
|
||
* überschrieben (manuelle Eingaben bleiben unangetastet).
|
||
*/
|
||
public string $autoCopyright = '';
|
||
|
||
public bool $isUploadFormOpen = false;
|
||
|
||
public function mount(int $pressReleaseId): void
|
||
{
|
||
$this->pressReleaseId = $pressReleaseId;
|
||
}
|
||
|
||
public function openUploadForm(): void
|
||
{
|
||
$this->isUploadFormOpen = true;
|
||
}
|
||
|
||
/**
|
||
* Wird das Modal clientseitig geschlossen (X-Button), soll der
|
||
* Formularstand nicht in die nächste Sitzung „kleben".
|
||
*/
|
||
public function updatedIsUploadFormOpen(bool $isOpen): void
|
||
{
|
||
if (!$isOpen) {
|
||
$this->resetUploadForm();
|
||
$this->resetErrorBag();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Beim Wechsel des Lizenztyps das Detail-Feld leeren — sonst klebt
|
||
* z. B. der zuvor gewählte CC-Wert (cc_by) im Freitextfeld
|
||
* „Lizenzdetails / Begründung" von „Sonstiges". Für KI-Bilder wird der
|
||
* öffentliche Bildnachweis vorgeschlagen (AI-Act-Kennzeichnung).
|
||
*/
|
||
public function updatedNewLicenseType(): void
|
||
{
|
||
$this->newLicenseDetail = '';
|
||
$this->newAiTermsConfirmed = false;
|
||
$this->resetErrorBag('newLicenseDetail');
|
||
|
||
// Automatisch erzeugte Vorschläge weichen dem Vorschlag des neuen Typs.
|
||
if ($this->newCopyright === $this->autoCopyright || str_starts_with($this->newCopyright, __('KI-generiert'))) {
|
||
$this->newCopyright = '';
|
||
$this->autoCopyright = '';
|
||
}
|
||
|
||
// Lizenztyp „KI-generiert" legt die KI-Kennzeichnung direkt fest.
|
||
if ($this->newLicenseType === ImageLicenseType::AiGenerated->value) {
|
||
$this->newAiDeclaration = AiImageDeclaration::AiGenerated->value;
|
||
$this->resetErrorBag('newAiDeclaration');
|
||
}
|
||
|
||
$this->suggestAiCopyright();
|
||
$this->suggestCopyrightFromAuthor();
|
||
}
|
||
|
||
/**
|
||
* Urheber-Angabe in den Bildnachweis-Vorschlag übernehmen, solange der
|
||
* Nutzer den Nachweis nicht selbst überschrieben hat.
|
||
*/
|
||
public function updatedNewAuthor(): void
|
||
{
|
||
$this->suggestCopyrightFromAuthor();
|
||
}
|
||
|
||
private function suggestCopyrightFromAuthor(): void
|
||
{
|
||
if ($this->newLicenseType === ImageLicenseType::AiGenerated->value) {
|
||
return;
|
||
}
|
||
|
||
if (filled($this->newCopyright) && $this->newCopyright !== $this->autoCopyright) {
|
||
return;
|
||
}
|
||
|
||
// Ohne „Foto:"-Präfix — die Veröffentlichungsseite stellt es dem
|
||
// Bildnachweis bereits selbst voran (sonst stünde dort „Foto: Foto: …").
|
||
$this->autoCopyright = filled($this->newAuthor) ? trim($this->newAuthor) : '';
|
||
$this->newCopyright = $this->autoCopyright;
|
||
}
|
||
|
||
/**
|
||
* Tool-Angabe in den Bildnachweis-Vorschlag übernehmen, solange der
|
||
* Nutzer den Nachweis nicht selbst überschrieben hat.
|
||
*/
|
||
public function updatedNewLicenseDetail(): void
|
||
{
|
||
$this->suggestAiCopyright();
|
||
}
|
||
|
||
private function suggestAiCopyright(): void
|
||
{
|
||
if ($this->newLicenseType !== ImageLicenseType::AiGenerated->value) {
|
||
return;
|
||
}
|
||
|
||
if (filled($this->newCopyright) && !str_starts_with($this->newCopyright, __('KI-generiert'))) {
|
||
return;
|
||
}
|
||
|
||
$this->newCopyright = filled($this->newLicenseDetail) ? __('KI-generiert (:tool)', ['tool' => trim($this->newLicenseDetail)]) : __('KI-generiert');
|
||
}
|
||
|
||
public function closeUploadForm(): void
|
||
{
|
||
$this->resetUploadForm();
|
||
$this->resetErrorBag();
|
||
|
||
$this->isUploadFormOpen = false;
|
||
}
|
||
|
||
public function saveImage(ImageService $imageService): 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;
|
||
}
|
||
|
||
$licenseType = ImageLicenseType::tryFrom($this->newLicenseType);
|
||
$requiresLicenseUrl = $licenseType?->requiresLicenseUrl() ?? false;
|
||
$requiresLicenseDetail = $licenseType?->requiresLicenseDetail() ?? false;
|
||
$isAiGenerated = $licenseType?->isAiGenerated() ?? false;
|
||
|
||
$this->validate(
|
||
[
|
||
'newImage' => ['required', 'image', 'mimes:jpeg,jpg,png,webp', 'max:' . (int) (ImageService::MAX_PRESS_RELEASE_IMAGE_BYTES / 1024)],
|
||
'newTitle' => ['nullable', 'string', 'max:120'],
|
||
'newCopyright' => ['required', 'string', 'max:255'],
|
||
'newAuthor' => ['required', 'string', 'max:255'],
|
||
'newLicenseType' => ['required', Rule::enum(ImageLicenseType::class)],
|
||
'newLicenseDetail' => [$requiresLicenseDetail ? 'required' : 'nullable', 'string', 'max:120'],
|
||
'newLicenseUrl' => [$requiresLicenseUrl ? 'required' : 'nullable', 'url', 'max:2048'],
|
||
'newSourceUrl' => ['nullable', 'url', 'max:2048'],
|
||
'newPeopleRightsStatus' => ['required', Rule::in(array_keys($this->peopleRightsOptions()))],
|
||
'newPropertyRightsStatus' => ['required', Rule::in(array_keys($this->propertyRightsOptions()))],
|
||
'newRightsNotes' => ['nullable', 'string', 'max:1000'],
|
||
'newRightsConfirmed' => ['accepted'],
|
||
'newAiTermsConfirmed' => [$isAiGenerated ? 'accepted' : 'nullable'],
|
||
'newAiDeclaration' => array_filter([
|
||
'required',
|
||
Rule::enum(AiImageDeclaration::class),
|
||
// Lizenztyp „KI-generiert" schließt „Originalfoto" aus.
|
||
$isAiGenerated ? Rule::in([AiImageDeclaration::AiGenerated->value, AiImageDeclaration::AiEdited->value]) : null,
|
||
]),
|
||
],
|
||
[
|
||
'newCopyright.required' => __('Bitte einen öffentlichen Bildnachweis angeben, z. B. Max Mustermann / Beispiel GmbH.'),
|
||
'newAuthor.required' => $isAiGenerated ? __('Bitte angeben, wer für die Erstellung verantwortlich ist (Person oder Firma).') : __('Bitte Urheber, Fotograf oder Rechteinhaber angeben.'),
|
||
'newLicenseType.required' => __('Bitte einen Lizenztyp wählen.'),
|
||
'newLicenseDetail.required' => $isAiGenerated ? __('Bitte das verwendete KI-Tool angeben, z. B. Midjourney, Gemini, ChatGPT, DALL·E, Adobe Firefly.') : __('Bitte die Lizenz genauer angeben.'),
|
||
'newLicenseUrl.required' => __('Für diesen Lizenztyp ist eine Nachweis-URL erforderlich.'),
|
||
'newPeopleRightsStatus.required' => __('Bitte angeben, ob erkennbare Personen abgebildet sind.'),
|
||
'newPropertyRightsStatus.required' => __('Bitte angeben, ob Marken, Kunstwerke oder private Orte sichtbar sind.'),
|
||
'newRightsConfirmed.accepted' => __('Bitte bestätigen, dass die Bildrechte geklärt sind.'),
|
||
'newAiTermsConfirmed.accepted' => __('Bitte bestätigen, dass die Anbieter-Bedingungen die kommerzielle Nutzung erlauben.'),
|
||
'newAiDeclaration.required' => __('Bitte die KI-Kennzeichnung des Bildes wählen: Originalfoto, KI-generiert oder KI-bearbeitet.'),
|
||
'newAiDeclaration.in' => __('Bei Lizenztyp „KI-generiert" muss die Kennzeichnung KI-generiert oder KI-bearbeitet sein.'),
|
||
],
|
||
);
|
||
|
||
$stored = $imageService->storePressReleaseImage($this->newImage, $pressRelease->id);
|
||
|
||
$pressRelease->images()->update(['is_preview' => false]);
|
||
|
||
$pressRelease->images()->create([
|
||
'disk' => $stored['disk'],
|
||
'path' => $stored['path'],
|
||
'variants' => $stored['variants'],
|
||
'title' => $this->newTitle ?: null,
|
||
'copyright' => $this->newCopyright ?: null,
|
||
'author' => $this->newAuthor,
|
||
'license_type' => $this->newLicenseType,
|
||
'license_detail' => $this->newLicenseDetail ?: null,
|
||
'license_url' => $this->newLicenseUrl ?: null,
|
||
'source_url' => $this->newSourceUrl ?: null,
|
||
'persons_consent' => $this->newPeopleRightsStatus === 'consent',
|
||
'people_rights_status' => $this->newPeopleRightsStatus,
|
||
'property_rights_status' => $this->newPropertyRightsStatus,
|
||
'rights_notes' => $this->newRightsNotes ?: null,
|
||
'rights_confirmed_at' => now(),
|
||
'is_ai_generated' => $isAiGenerated,
|
||
'ai_declaration' => $this->newAiDeclaration,
|
||
'is_preview' => true,
|
||
'sort_order' => ((int) $pressRelease->images()->max('sort_order')) + 1,
|
||
'width' => $stored['width'],
|
||
'height' => $stored['height'],
|
||
'mime' => $stored['mime'],
|
||
]);
|
||
|
||
$this->resetUploadForm();
|
||
$this->isUploadFormOpen = false;
|
||
|
||
$this->dispatch('title-image-changed');
|
||
|
||
Flux::toast(text: __('Titelbild hochgeladen.'), variant: 'success');
|
||
}
|
||
|
||
public function removeNewImage(): void
|
||
{
|
||
$this->reset('newImage');
|
||
$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 remove(int $imageId, ImageService $imageService): void
|
||
{
|
||
$pressRelease = $this->getPressRelease();
|
||
$this->authorize('update', $pressRelease);
|
||
|
||
if (!$this->canChangeImages($pressRelease)) {
|
||
return;
|
||
}
|
||
|
||
$image = $pressRelease->images()->whereKey($imageId)->first();
|
||
|
||
if (!$image) {
|
||
return;
|
||
}
|
||
|
||
$imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||
$image->delete();
|
||
|
||
$this->dispatch('title-image-changed');
|
||
|
||
Flux::toast(text: __('Titelbild entfernt.'), variant: 'success');
|
||
}
|
||
|
||
public function with(): array
|
||
{
|
||
$pressRelease = $this->getPressRelease();
|
||
|
||
return [
|
||
'titleImage' => $this->titleImageFor($pressRelease),
|
||
'canEdit' => auth()->user()?->can('update', $pressRelease) === true && $this->canChangeImages($pressRelease),
|
||
'licenseTypeOptions' => ImageLicenseType::options(),
|
||
'ccLicenseOptions' => $this->ccLicenseOptions(),
|
||
'peopleRightsOptions' => $this->peopleRightsOptions(),
|
||
'propertyRightsOptions' => $this->propertyRightsOptions(),
|
||
'licenseUrlRequired' => ImageLicenseType::tryFrom($this->newLicenseType)?->requiresLicenseUrl() ?? false,
|
||
'licenseDetailRequired' => ImageLicenseType::tryFrom($this->newLicenseType)?->requiresLicenseDetail() ?? false,
|
||
'showsCcWarning' => $this->newLicenseType === ImageLicenseType::CreativeCommons->value,
|
||
'showsAiSection' => $this->newLicenseType === ImageLicenseType::AiGenerated->value,
|
||
'showsRightsWarning' => $this->shouldShowRightsWarning(),
|
||
];
|
||
}
|
||
|
||
private function getPressRelease(): PressRelease
|
||
{
|
||
return PressRelease::withoutGlobalScope(PortalScope::class)->findOrFail($this->pressReleaseId);
|
||
}
|
||
|
||
private function canChangeImages(PressRelease $pressRelease): bool
|
||
{
|
||
if (auth()->user()?->canAccessAdmin()) {
|
||
return !in_array($pressRelease->status, [PressReleaseStatus::Archived], true);
|
||
}
|
||
|
||
return in_array($pressRelease->status, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected], true);
|
||
}
|
||
|
||
private function titleImageFor(PressRelease $pressRelease): ?PressReleaseImage
|
||
{
|
||
return $pressRelease->images()->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first();
|
||
}
|
||
|
||
private function resetUploadForm(): void
|
||
{
|
||
$this->reset(['newImage', 'newTitle', 'newCopyright', 'newAuthor', 'newLicenseType', 'newLicenseDetail', 'newLicenseUrl', 'newSourceUrl', 'newPeopleRightsStatus', 'newPropertyRightsStatus', 'newRightsNotes', 'newRightsConfirmed', 'newAiTermsConfirmed', 'newAiDeclaration', 'autoCopyright']);
|
||
}
|
||
|
||
/**
|
||
* @return array<string, string>
|
||
*/
|
||
private function ccLicenseOptions(): array
|
||
{
|
||
return [
|
||
'cc0' => 'CC0',
|
||
'cc_by' => 'CC BY',
|
||
'cc_by_sa' => 'CC BY-SA',
|
||
'cc_by_nd' => 'CC BY-ND',
|
||
'cc_by_nc' => 'CC BY-NC',
|
||
'cc_by_nc_sa' => 'CC BY-NC-SA',
|
||
'cc_by_nc_nd' => 'CC BY-NC-ND',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<string, string>
|
||
*/
|
||
private function peopleRightsOptions(): array
|
||
{
|
||
return [
|
||
'none' => __('Nein, keine erkennbaren Personen'),
|
||
'consent' => __('Ja, Einwilligung liegt vor'),
|
||
'public_event' => __('Ja, öffentliche Veranstaltung / redaktioneller Kontext'),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array<string, string>
|
||
*/
|
||
private function propertyRightsOptions(): array
|
||
{
|
||
return [
|
||
'none' => __('Nein'),
|
||
'cleared' => __('Ja, Rechte / Nutzung sind geklärt'),
|
||
];
|
||
}
|
||
|
||
private function shouldShowRightsWarning(): bool
|
||
{
|
||
$restrictedCcLicense = str_contains($this->newLicenseDetail, '_nc') || str_contains($this->newLicenseDetail, '_nd');
|
||
|
||
return $this->newLicenseType === ImageLicenseType::Other->value || $restrictedCcLicense;
|
||
}
|
||
}; ?>
|
||
|
||
<flux:card>
|
||
<div class="flex items-center justify-between">
|
||
<flux:heading size="md">{{ __('Titelbild') }}</flux:heading>
|
||
<flux:badge color="{{ $titleImage ? 'green' : 'zinc' }}" size="sm">
|
||
{{ $titleImage ? __('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->author)
|
||
<p class="m-0 truncate text-xs text-zinc-500" title="{{ $titleImage->author }}">©
|
||
{{ $titleImage->author }}</p>
|
||
@endif
|
||
@if ($titleImage->copyright)
|
||
<p class="m-0 truncate text-xs text-zinc-500" title="{{ $titleImage->copyright }}">
|
||
{{ __('Bildnachweis: :copyright', ['copyright' => $titleImage->copyright]) }}
|
||
</p>
|
||
@endif
|
||
<div class="flex flex-wrap items-center gap-1 text-xs text-zinc-400">
|
||
@if ($titleImage->is_ai_generated)
|
||
<flux:badge color="purple" size="xs">{{ __('KI-generiert') }}</flux:badge>
|
||
@endif
|
||
@if ($titleImage->license_type)
|
||
<flux:badge color="zinc" size="xs">{{ $titleImage->license_type->label() }}
|
||
</flux:badge>
|
||
@endif
|
||
@if ($titleImage->width && $titleImage->height)
|
||
<span>{{ $titleImage->width }}×{{ $titleImage->height }}</span>
|
||
@endif
|
||
</div>
|
||
</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>
|
||
</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, Herkunft und Bildrechte angeben — fertig.') }}
|
||
</flux:subheading>
|
||
</div>
|
||
|
||
{{-- ===== Schritt 1 · Bild auswählen ===== --}}
|
||
<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 auswählen') }}</span>
|
||
</div>
|
||
|
||
@if (!$newImage)
|
||
<flux:file-upload wire:model="newImage" accept="image/jpeg,image/png,image/webp"
|
||
:description="__('JPG/PNG/WebP, max. 16 MB. Wird als Titelbild gespeichert und ersetzt den Platzhalter.')">
|
||
<flux:file-upload.dropzone :heading="__('Bild hierher ziehen oder klicken')"
|
||
:text="__('JPG, PNG oder WebP · max. 16 MB')" 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>
|
||
|
||
<p class="m-0 text-xs text-zinc-400">
|
||
{{ __('Nach der Bildauswahl folgen drei kurze Schritte zu Herkunft und Bildrechten.') }}
|
||
</p>
|
||
@else
|
||
{{-- Große Vorschau im Titelbild-Format, damit das Motiv
|
||
vor dem Hochladen wirklich beurteilbar ist. --}}
|
||
<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 object-cover" />
|
||
@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>
|
||
@endif
|
||
<flux:error name="newImage" />
|
||
</section>
|
||
|
||
{{-- Die weiteren Schritte erscheinen erst, wenn ein Bild gewählt
|
||
wurde — so bleibt der Einstieg übersichtlich. --}}
|
||
@if ($newImage)
|
||
{{-- ===== Schritt 2 · Herkunft & Bildnachweis ===== --}}
|
||
<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">2</span>
|
||
<span
|
||
class="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{{ __('Herkunft & Bildnachweis') }}</span>
|
||
</div>
|
||
|
||
<div class="grid gap-3 sm:grid-cols-2">
|
||
<flux:select wire:model.live="newLicenseType" :label="__('Woher stammt das Bild?')"
|
||
:badge="__('Pflicht')" required>
|
||
<flux:select.option value="" disabled>{{ __('Bitte wählen…') }}
|
||
</flux:select.option>
|
||
@foreach ($licenseTypeOptions as $value => $label)
|
||
<flux:select.option :value="$value">{{ $label }}</flux:select.option>
|
||
@endforeach
|
||
</flux:select>
|
||
<flux:input wire:model.live.debounce.400ms="newAuthor"
|
||
:label="$showsAiSection ? __('Verantwortlich für die Erstellung (Person/Firma)') : __(
|
||
'Urheber / Fotograf / Rechteinhaber')"
|
||
:badge="__('Pflicht')" required />
|
||
</div>
|
||
|
||
@if ($showsAiSection)
|
||
<flux:input wire:model.live.debounce.400ms="newLicenseDetail"
|
||
:label="__('Verwendetes KI-Tool')" :badge="__('Pflicht')"
|
||
placeholder="{{ __('z. B. Midjourney, Gemini, ChatGPT, DALL·E, Adobe Firefly') }}"
|
||
required />
|
||
|
||
<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">
|
||
{{ __('KI-generierte Bilder haben keinen menschlichen Urheber — maßgeblich sind die Nutzungsbedingungen des KI-Anbieters. Das Bild wird öffentlich als KI-generiert gekennzeichnet (Transparenzpflicht, EU AI Act). Achten Sie darauf, dass keine realen Personen, Marken oder geschützten Werke erkennbar nachgebildet werden.') }}
|
||
</div>
|
||
|
||
<flux:switch wire:model="newAiTermsConfirmed" align="right"
|
||
:label="__('Anbieter-Bedingungen geprüft')"
|
||
:description="__('Ich bestätige, dass die Nutzungsbedingungen des KI-Anbieters die kommerzielle Nutzung und Veröffentlichung dieses Bildes erlauben.')" />
|
||
@elseif ($newLicenseType === \App\Enums\ImageLicenseType::CreativeCommons->value)
|
||
<flux:select wire:model.live="newLicenseDetail" :label="__('Creative-Commons-Lizenz')"
|
||
required>
|
||
<flux:select.option value="" disabled>{{ __('Bitte wählen…') }}
|
||
</flux:select.option>
|
||
@foreach ($ccLicenseOptions as $value => $label)
|
||
<flux:select.option :value="$value">{{ $label }}</flux:select.option>
|
||
@endforeach
|
||
</flux:select>
|
||
|
||
<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">
|
||
{{ __('Creative-Commons-Lizenzen können Einschränkungen enthalten. Bitte prüfen Sie, ob kommerzielle Nutzung, Bearbeitung und Veröffentlichung als Titelbild erlaubt sind.') }}
|
||
</div>
|
||
@elseif($licenseDetailRequired)
|
||
<flux:input wire:model="newLicenseDetail" :label="__('Lizenzdetails / Begründung')"
|
||
:description="__('Bitte kurz erklären, warum die Nutzung erlaubt ist.')" required />
|
||
@endif
|
||
|
||
@if ($licenseUrlRequired)
|
||
<flux:input wire:model="newLicenseUrl" type="url"
|
||
:label="__('Quelle oder Lizenznachweis-URL')" :badge="__('Pflicht')"
|
||
placeholder="https://…" required />
|
||
@endif
|
||
|
||
{{-- KI-Kennzeichnung je Bild (Art. 50 KI-VO): Pflichtauswahl.
|
||
Beim Lizenztyp „KI-generiert" wird sie automatisch gesetzt
|
||
und deshalb hier nicht mehr abgefragt. --}}
|
||
@if (!$showsAiSection)
|
||
<flux:radio.group wire:model="newAiDeclaration"
|
||
:label="__('KI-Kennzeichnung dieses Bildes')" :badge="__('Pflicht')"
|
||
:description="__('KI-generierte oder -bearbeitete Bilder erhalten öffentlich einen neutralen Bildhinweis (EU AI Act).')"
|
||
required>
|
||
@foreach (\App\Enums\AiImageDeclaration::cases() as $aiImageOption)
|
||
<flux:radio :value="$aiImageOption->value" :label="$aiImageOption->label()" />
|
||
@endforeach
|
||
</flux:radio.group>
|
||
<flux:error name="newAiDeclaration" />
|
||
@endif
|
||
|
||
<flux:input wire:model="newCopyright" :label="__('Öffentlicher Bildnachweis')"
|
||
:badge="__('Pflicht')"
|
||
:description="__('Wird öffentlich unter dem Bild angezeigt („Foto: …") — Vorschlag anpassbar.')"
|
||
:placeholder="__('z. B. Max Mustermann / Beispiel GmbH')" required />
|
||
</section>
|
||
|
||
{{-- ===== Schritt 3 · Personen & Rechte Dritter ===== --}}
|
||
<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">{{ __('Personen & Rechte Dritter') }}</span>
|
||
</div>
|
||
<div class="grid gap-4 sm:grid-cols-2">
|
||
<flux:radio.group wire:model.live="newPeopleRightsStatus"
|
||
:label="__('Sind erkennbare Personen abgebildet?')" required>
|
||
@foreach ($peopleRightsOptions as $value => $label)
|
||
<flux:radio :value="$value" :label="$label" />
|
||
@endforeach
|
||
</flux:radio.group>
|
||
|
||
<flux:radio.group wire:model.live="newPropertyRightsStatus"
|
||
:label="__('Sind Marken, Kunstwerke, geschützte Werke oder private Orte sichtbar?')"
|
||
required>
|
||
@foreach ($propertyRightsOptions as $value => $label)
|
||
<flux:radio :value="$value" :label="$label" />
|
||
@endforeach
|
||
</flux:radio.group>
|
||
</div>
|
||
|
||
@if (in_array($newPeopleRightsStatus, ['consent', 'public_event'], true))
|
||
<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">
|
||
{{ __('Bei erkennbaren Personen können zusätzlich Persönlichkeits- oder Datenschutzrechte betroffen sein. Bitte stellen Sie sicher, dass die Veröffentlichung zulässig ist.') }}
|
||
</div>
|
||
@endif
|
||
|
||
@if ($showsRightsWarning)
|
||
<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">
|
||
{{ __('Diese Auswahl kann Einschränkungen enthalten. Bitte laden Sie das Bild nur hoch, wenn die Nutzung, Bearbeitung und Veröffentlichung als Titelbild erlaubt ist.') }}
|
||
</div>
|
||
@endif
|
||
</section>
|
||
|
||
{{-- ===== Optionale Angaben (eingeklappt) ===== --}}
|
||
<details class="group border-t border-zinc-200 pt-5 dark:border-zinc-700">
|
||
<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">
|
||
— {{ __('Alt-Text, Quellen-URLs, interne Hinweise') }}
|
||
</span>
|
||
</summary>
|
||
<div class="mt-3 space-y-3">
|
||
<flux:input wire:model="newTitle" :label="__('Titel / Alt-Text (optional)')"
|
||
:description="__('Beschreibt das Bild für Suchmaschinen und Screenreader.')" />
|
||
<div class="grid gap-3 sm:grid-cols-2">
|
||
@unless ($licenseUrlRequired)
|
||
<flux:input wire:model="newLicenseUrl" type="url"
|
||
:label="__('Quelle oder Lizenznachweis-URL (optional)')"
|
||
placeholder="https://…" />
|
||
@endunless
|
||
<flux:input wire:model="newSourceUrl" type="url"
|
||
:label="__('Weitere Quelle / Fundstelle (optional)')" placeholder="https://…" />
|
||
</div>
|
||
<flux:textarea wire:model="newRightsNotes"
|
||
:label="__('Interne Hinweise zu Rechten / Freigaben (optional)')" rows="3" />
|
||
</div>
|
||
</details>
|
||
|
||
{{-- ===== Schritt 4 · 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">4</span>
|
||
<span
|
||
class="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{{ __('Bestätigung') }}</span>
|
||
</div>
|
||
|
||
<flux:switch wire:model="newRightsConfirmed" align="right" :label="__('Rechte bestätigt')"
|
||
:description="__('Ich bestätige, dass ich über die erforderlichen Rechte zur Veröffentlichung dieses Bildes verfüge und die Verantwortung für die Richtigkeit meiner Angaben übernehme. Dies umfasst Urheberrechte, Nutzungsrechte, Persönlichkeitsrechte abgebildeter Personen sowie gegebenenfalls Marken-, Eigentums- oder sonstige Rechte Dritter. Mir ist bewusst, dass ich für fehlerhafte oder unvollständige Angaben verantwortlich bin.')" />
|
||
</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>
|