PM-Editor UX: Kategorie in die Schreibfläche, Bild-Upload als Modal-Flow
Kategorie-Pflichtfeld direkt unter die Firma statt in die Sidebar. Titelbild-Upload öffnet ein Modal: erst Bild wählen, dann erscheinen die gestrafften Schritte (Herkunft & Bildnachweis mit Vorschlag aus dem Urheber, Personen & Rechte, Bestätigung), Optionales eingeklappt. Bildrechte-Checkbox im Einreichen-Modal nur noch mit eigenem Titelbild. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c387048d7e
commit
2882bc44b2
10 changed files with 705 additions and 391 deletions
|
|
@ -480,6 +480,9 @@ class PressRelease extends Model
|
|||
$models = static::query()->withoutGlobalScopes()
|
||||
->whereKey($pageIds)
|
||||
->with('company:id,name')
|
||||
// Für das Einreichungs-Modal je Zeile: ohne eigenes Bild entfällt
|
||||
// die Bildrechte-Bestätigung.
|
||||
->withCount('images')
|
||||
->orderBy($sortColumn, $sortDir)
|
||||
->orderBy('id', $sortDir)
|
||||
->get();
|
||||
|
|
|
|||
42
resources/views/components/portal/pr-error-summary.blade.php
Normal file
42
resources/views/components/portal/pr-error-summary.blade.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{{--
|
||||
Persistente Fehler-Zusammenfassung der PM-Editoren.
|
||||
|
||||
Der Danger-Toast verschwindet nach wenigen Sekunden und die Feld-Errors
|
||||
verteilen sich über Schreibfläche und Sidebar — diese Box bleibt direkt
|
||||
bei den Speichern-Buttons stehen, bis alle Fehler behoben sind. Jeder
|
||||
Eintrag scrollt per Klick zum zugehörigen Panel (data-error-anchor).
|
||||
|
||||
Verwendung: <x-portal.pr-error-summary :errors="$errors" />
|
||||
--}}
|
||||
@props(['errors'])
|
||||
|
||||
@php
|
||||
/** Fehler-Keys ohne eigenes Panel springen zum Panel ihres Themas. */
|
||||
$anchorAliases = [
|
||||
'scheduledDate' => 'scheduledAt',
|
||||
'scheduledTime' => 'scheduledAt',
|
||||
'publishMode' => 'scheduledAt',
|
||||
'language' => 'backlinkUrl',
|
||||
'keywords' => 'backlinkUrl',
|
||||
];
|
||||
@endphp
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="pr-error-summary" role="alert" x-data>
|
||||
<div class="head">
|
||||
<flux:icon name="exclamation-circle" variant="mini" class="size-4 flex-shrink-0" />
|
||||
<span>{{ trans_choice('{1}Speichern nicht möglich — bitte korrigiere:|[2,*]Speichern nicht möglich — bitte korrigiere :count Felder:', count($errors->keys())) }}</span>
|
||||
</div>
|
||||
<ul>
|
||||
@foreach ($errors->keys() as $errorKey)
|
||||
@php($anchor = $anchorAliases[$errorKey] ?? $errorKey)
|
||||
<li wire:key="pr-error-summary-{{ $errorKey }}">
|
||||
<button
|
||||
type="button"
|
||||
x-on:click="document.querySelector('[data-error-anchor={{ $anchor }}]')?.scrollIntoView({ behavior: 'smooth', block: 'center' })"
|
||||
>{{ $errors->first($errorKey) }}</button>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
'confirmLabel' => null,
|
||||
'quotaTotal' => null,
|
||||
'quotaRemaining' => null,
|
||||
'hasTitleImage' => true,
|
||||
])
|
||||
|
||||
{{--
|
||||
|
|
@ -24,6 +25,10 @@
|
|||
speichert und dann zur Buchungsseite leitet. ZWINGEND in Erstellen/Bearbeiten
|
||||
setzen — sonst ginge der erfasste Inhalt beim Sprung verloren. Fehlt sie
|
||||
(z. B. Detailansicht, dort ist bereits gespeichert), wird direkt verlinkt.
|
||||
|
||||
`has-title-image`: Ohne eigenes Titelbild (nur Platzhalter) entfällt die
|
||||
Bildrechte-Bestätigung — es gibt nichts zu bestätigen. Default true, damit
|
||||
die Checkbox im Zweifel lieber einmal zu viel erscheint.
|
||||
--}}
|
||||
@php($bookingRequired = ! (auth()->user()?->hasActiveBooking() ?? true))
|
||||
|
||||
|
|
@ -62,7 +67,9 @@
|
|||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-5" x-data="{ agb: false, images: false, contact: false, waiver: false, ai: false }">
|
||||
{{-- 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>
|
||||
<flux:text class="eyebrow muted">{{ __('Veröffentlichung') }}</flux:text>
|
||||
<flux:heading size="lg">{{ __('Pressemitteilung zur Prüfung einreichen') }}</flux:heading>
|
||||
|
|
@ -137,7 +144,7 @@
|
|||
<div class="panel-head" style="padding:10px 16px;">
|
||||
<span class="section-eyebrow">{{ __('Ihre Bestätigungen') }}</span>
|
||||
<span class="shrink-0 whitespace-nowrap text-[11.5px] tabular-nums text-[color:var(--color-ink-3)]"
|
||||
x-text="[agb, images, contact, waiver, ai].filter(Boolean).length + ' / 5'"></span>
|
||||
x-text="[agb, {{ $hasTitleImage ? 'images, ' : '' }}contact, waiver, ai].filter(Boolean).length + ' / {{ $hasTitleImage ? 5 : 4 }}'"></span>
|
||||
</div>
|
||||
<div class="divide-y divide-[color:var(--color-bg-rule)]">
|
||||
<div class="px-4 py-3">
|
||||
|
|
@ -147,13 +154,15 @@
|
|||
:label="__('Der Inhalt entspricht den AGB und gesetzlichen Vorgaben.')"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<flux:switch
|
||||
align="left"
|
||||
x-on:change="images = $event.target.checked"
|
||||
:label="__('Alle Bildrechte sind geklärt.')"
|
||||
/>
|
||||
</div>
|
||||
@if ($hasTitleImage)
|
||||
<div class="px-4 py-3">
|
||||
<flux:switch
|
||||
align="left"
|
||||
x-on:change="images = $event.target.checked"
|
||||
:label="__('Alle Bildrechte sind geklärt.')"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
<div class="px-4 py-3">
|
||||
<flux:switch
|
||||
align="left"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\ImageLicenseType;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
|
|
@ -49,6 +50,20 @@ new class extends Component {
|
|||
|
||||
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
|
||||
|
|
@ -61,6 +76,18 @@ new class extends Component {
|
|||
$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
|
||||
|
|
@ -73,7 +100,43 @@ new class extends Component {
|
|||
$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, __('Bild: 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;
|
||||
}
|
||||
|
||||
$this->autoCopyright = filled($this->newAuthor) ? __('Foto: :author', ['author' => trim($this->newAuthor)]) : '';
|
||||
$this->newCopyright = $this->autoCopyright;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -143,6 +206,12 @@ new class extends Component {
|
|||
'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. Foto: Max Mustermann / Beispiel GmbH.'),
|
||||
|
|
@ -154,6 +223,8 @@ new class extends Component {
|
|||
'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.'),
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -178,6 +249,7 @@ new class extends Component {
|
|||
'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'],
|
||||
|
|
@ -275,7 +347,7 @@ new class extends Component {
|
|||
|
||||
private function resetUploadForm(): void
|
||||
{
|
||||
$this->reset(['newImage', 'newTitle', 'newCopyright', 'newAuthor', 'newLicenseType', 'newLicenseDetail', 'newLicenseUrl', 'newSourceUrl', 'newPeopleRightsStatus', 'newPropertyRightsStatus', 'newRightsNotes', 'newRightsConfirmed', 'newAiTermsConfirmed']);
|
||||
$this->reset(['newImage', 'newTitle', 'newCopyright', 'newAuthor', 'newLicenseType', 'newLicenseDetail', 'newLicenseUrl', 'newSourceUrl', 'newPeopleRightsStatus', 'newPropertyRightsStatus', 'newRightsNotes', 'newRightsConfirmed', 'newAiTermsConfirmed', 'newAiDeclaration', 'autoCopyright']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -388,24 +460,29 @@ new class extends Component {
|
|||
</div>
|
||||
</div>
|
||||
@elseif($canEdit)
|
||||
@if (!$isUploadFormOpen)
|
||||
<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
|
||||
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>
|
||||
@else
|
||||
<form wire:submit="saveImage"
|
||||
class="mt-4 space-y-6 rounded-md border border-zinc-200 p-4 dark:border-zinc-700 sm:p-5">
|
||||
<flux:heading size="xs">{{ __('Titelbild hochladen') }}</flux:heading>
|
||||
|
||||
<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">
|
||||
|
|
@ -427,6 +504,10 @@ new class extends Component {
|
|||
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. --}}
|
||||
|
|
@ -460,151 +541,185 @@ new class extends Component {
|
|||
<flux:error name="newImage" />
|
||||
</section>
|
||||
|
||||
{{-- ===== Schritt 2 · Öffentliche Bildinfos ===== --}}
|
||||
<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">{{ __('Bildinformationen (öffentlich sichtbar)') }}</span>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<flux:input wire:model="newTitle" :label="__('Titel / Alt-Text (optional)')" />
|
||||
{{-- 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')"
|
||||
:placeholder="__('Wird öffentlich angezeigt, z. B. Foto: Max Mustermann / Beispiel GmbH.')"
|
||||
required />
|
||||
</div>
|
||||
</section>
|
||||
:description="__('Wird öffentlich unter dem Bild angezeigt — Vorschlag anpassbar.')"
|
||||
:placeholder="__('z. B. Foto: Max Mustermann / Beispiel GmbH')" required />
|
||||
</section>
|
||||
|
||||
{{-- ===== Schritt 3 · Herkunft & Lizenz ===== --}}
|
||||
<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">{{ __('Herkunft & Lizenz') }}</span>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<flux:input wire:model="newAuthor"
|
||||
:label="$showsAiSection ? __('Verantwortlich für die Erstellung (Person/Firma)') : __(
|
||||
'Urheber / Fotograf / Rechteinhaber')"
|
||||
:badge="__('Pflicht')" required />
|
||||
<flux:select wire:model.live="newLicenseType" :label="__('Lizenztyp')" :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>
|
||||
</div>
|
||||
{{-- ===== 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>
|
||||
|
||||
@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.') }}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@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
|
||||
|
||||
<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.') }}
|
||||
@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>
|
||||
@elseif($licenseDetailRequired)
|
||||
<flux:input wire:model="newLicenseDetail" :label="__('Lizenzdetails / Begründung')"
|
||||
:description="__('Bitte kurz erklären, warum die Nutzung erlaubt ist.')" required />
|
||||
</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 class="grid gap-3 sm:grid-cols-2">
|
||||
<flux:input wire:model="newLicenseUrl" type="url"
|
||||
:label="$licenseUrlRequired ? __('Quelle oder Lizenznachweis-URL') : __(
|
||||
'Quelle oder Lizenznachweis-URL (optional)')"
|
||||
:required="$licenseUrlRequired" placeholder="https://…" />
|
||||
<flux:input wire:model="newSourceUrl" type="url"
|
||||
:label="__('Weitere Quelle / Fundstelle (optional)')" placeholder="https://…" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- ===== Schritt 4 · 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">4</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>
|
||||
|
||||
{{-- ===== Schritt 5 · 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">5</span>
|
||||
<span
|
||||
class="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{{ __('Bestätigung') }}</span>
|
||||
</div>
|
||||
|
||||
<flux:textarea wire:model="newRightsNotes"
|
||||
:label="__('Interne Hinweise zu Rechten / Freigaben (optional)')" rows="3" />
|
||||
|
||||
<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.')" />
|
||||
|
||||
<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>
|
||||
<flux:button type="submit" variant="primary" icon="arrow-up-tray">{{ __('Hochladen') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
@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" />
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ use Livewire\Volt\Component;
|
|||
|
||||
new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class extends Component
|
||||
{
|
||||
use \App\Livewire\Concerns\InteractsWithAiDeclaration;
|
||||
use \App\Livewire\Concerns\InteractsWithPressReleaseContacts;
|
||||
use \App\Livewire\Concerns\InteractsWithPressReleaseScheduling;
|
||||
use \App\Livewire\Concerns\InteractsWithPressReleaseTags;
|
||||
|
|
@ -297,6 +298,12 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
|
||||
public function save(string $submitStatus = 'draft'): void
|
||||
{
|
||||
// KI-Kennzeichnung (Art. 50 KI-VO): Pflicht nur beim Einreichen —
|
||||
// zuerst prüfen, damit die Fehler direkt im Submit-Modal erscheinen.
|
||||
if ($submitStatus === 'review') {
|
||||
$this->validateAiDeclaration();
|
||||
}
|
||||
|
||||
$pr = $this->persistDraft();
|
||||
|
||||
if (! $pr) {
|
||||
|
|
@ -374,6 +381,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
? $this->resolveScheduledAt()
|
||||
: null,
|
||||
'embargo_at' => null,
|
||||
...$this->aiDeclarationAttributes(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -485,7 +493,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
<div class="space-y-6 min-w-0">
|
||||
|
||||
{{-- 1) FIRMA-SELEKTOR --}}
|
||||
<section class="panel">
|
||||
<section class="panel @error('companyId') is-invalid @enderror" data-error-anchor="companyId">
|
||||
<div class="p-4 space-y-3">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<span class="pr-form-label shrink-0" style="margin-bottom:0;">{{ __('Für Firma') }}</span>
|
||||
|
|
@ -534,8 +542,31 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
<flux:error name="companyId" />
|
||||
</section>
|
||||
|
||||
{{-- 2) TITEL --}}
|
||||
<section class="panel">
|
||||
{{-- 2) KATEGORIE (Pflichtfeld — bewusst prominent vor der Headline) --}}
|
||||
<section class="panel @error('categoryId') is-invalid @enderror" data-error-anchor="categoryId">
|
||||
<div class="p-4 space-y-3">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<span class="pr-form-label shrink-0" style="margin-bottom:0;">
|
||||
{{ __('Kategorie') }} <span class="req">*</span>
|
||||
</span>
|
||||
<div class="w-full sm:flex-1">
|
||||
<flux:select wire:model.live="categoryId">
|
||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||
@foreach ($categories as $cat)
|
||||
<option value="{{ $cat->id }}">{{ $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
</div>
|
||||
<span class="block text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}
|
||||
</span>
|
||||
<flux:error name="categoryId" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- 3) TITEL --}}
|
||||
<section class="panel @error('title') is-invalid @enderror" data-error-anchor="title">
|
||||
<div class="p-5 pb-4">
|
||||
<div class="flex items-center justify-between mb-2 gap-4">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">
|
||||
|
|
@ -566,8 +597,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{{-- 3) SUBTITLE --}}
|
||||
<section class="panel">
|
||||
{{-- 4) SUBTITLE --}}
|
||||
<section class="panel @error('subtitle') is-invalid @enderror" data-error-anchor="subtitle">
|
||||
<div class="p-5 pb-4">
|
||||
<div class="flex items-center justify-between mb-2 gap-4">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">
|
||||
|
|
@ -593,8 +624,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{{-- 4) FLIESSTEXT --}}
|
||||
<section class="panel">
|
||||
{{-- 5) FLIESSTEXT --}}
|
||||
<section class="panel @error('text') is-invalid @enderror" data-error-anchor="text">
|
||||
<div class="p-5 pb-4">
|
||||
<div class="flex items-center justify-between mb-2 gap-4">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">
|
||||
|
|
@ -636,7 +667,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{{-- 5) TITELBILD --}}
|
||||
{{-- 6) TITELBILD --}}
|
||||
<section class="panel">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-3 gap-4">
|
||||
|
|
@ -692,7 +723,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
{{-- Titelbild-Platzhalter-Auswahl --}}
|
||||
<livewire:components.press-release-placeholder-picker :current="$placeholderVariant" />
|
||||
|
||||
{{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT
|
||||
{{-- 7) ANHÄNGE — TEMPORÄR DEAKTIVIERT
|
||||
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung
|
||||
(Virus-/Malware-Scan, MIME-Validierung, Storage-Quoten).
|
||||
Wird in einer späteren Phase aktiviert.
|
||||
|
|
@ -713,8 +744,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</section>
|
||||
--}}
|
||||
|
||||
{{-- 7) BOILERPLATE --}}
|
||||
<section class="panel">
|
||||
{{-- 8) BOILERPLATE --}}
|
||||
<section class="panel @error('boilerplateOverride') is-invalid @enderror" data-error-anchor="boilerplateOverride">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-3 gap-4">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">
|
||||
|
|
@ -775,6 +806,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
<span class="badge muted dot">{{ __('Entwurf') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<x-portal.pr-error-summary :errors="$errors" />
|
||||
|
||||
<x-portal.pr-presubmit-check :checks="$this->presubmitChecks" />
|
||||
|
||||
<flux:modal.trigger name="confirm-submit-review">
|
||||
|
|
@ -806,28 +839,6 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
</article>
|
||||
|
||||
{{-- Kategorie (Pflichtfeld - prominent direkt unter Status) --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">
|
||||
{{ __('Kategorie') }}
|
||||
<span class="text-[color:var(--color-err)]">*</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<flux:field>
|
||||
<flux:select wire:model.live="categoryId">
|
||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||
@foreach ($categories as $cat)
|
||||
<option value="{{ $cat->id }}">{{ $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="categoryId" />
|
||||
<flux:description>{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}</flux:description>
|
||||
</flux:field>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- Portal (Read-only, Badge in Portal-Farbe) --}}
|
||||
@php
|
||||
$portalPillClass = 'portal-pill';
|
||||
|
|
@ -857,7 +868,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</article>
|
||||
|
||||
{{-- Pressekontakt --}}
|
||||
<article class="panel">
|
||||
<article class="panel @error('contactId') is-invalid @enderror" data-error-anchor="contactId">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Pressekontakt') }}</span>
|
||||
</div>
|
||||
|
|
@ -966,7 +977,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</article>
|
||||
|
||||
{{-- Veröffentlichung --}}
|
||||
<article class="panel">
|
||||
<article class="panel @if ($errors->hasAny(['publishMode', 'scheduledDate', 'scheduledTime', 'scheduledAt'])) is-invalid @endif" data-error-anchor="scheduledAt">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Veröffentlichung') }}</span>
|
||||
</div>
|
||||
|
|
@ -1026,7 +1037,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</article>
|
||||
|
||||
{{-- Weitere Felder --}}
|
||||
<article class="panel">
|
||||
<article class="panel @if ($errors->hasAny(['backlinkUrl', 'language', 'keywords'])) is-invalid @endif" data-error-anchor="backlinkUrl">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Weitere Felder') }}</span>
|
||||
</div>
|
||||
|
|
@ -1066,12 +1077,15 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
|
||||
{{-- Einreichungs-Modal (öffnet über „Zur Prüfung senden") --}}
|
||||
{{-- Im Erstellen-Formular gibt es noch keinen Bild-Upload (der folgt im
|
||||
Editor nach dem Entwurf-Sichern), daher entfällt die Bildrechte-Frage. --}}
|
||||
<x-press-release-submit-modal
|
||||
name="confirm-submit-review"
|
||||
action="save('review')"
|
||||
booking-action="saveDraftAndChooseBooking"
|
||||
:confirm-label="__('Zur Prüfung senden')"
|
||||
:quota-total="$quotaTotal"
|
||||
:quota-remaining="$quotaRemaining" />
|
||||
:quota-remaining="$quotaRemaining"
|
||||
:has-title-image="false" />
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use App\Models\Company;
|
|||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use App\Services\PressRelease\AiDeclarationRequiredException;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\BookingRequiredException;
|
||||
use App\Services\PressRelease\PressReleaseCoverImage;
|
||||
|
|
@ -24,6 +25,7 @@ use Livewire\Volt\Component;
|
|||
|
||||
new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] class extends Component
|
||||
{
|
||||
use \App\Livewire\Concerns\InteractsWithAiDeclaration;
|
||||
use \App\Livewire\Concerns\InteractsWithPressReleaseContacts;
|
||||
use \App\Livewire\Concerns\InteractsWithPressReleaseScheduling;
|
||||
use \App\Livewire\Concerns\InteractsWithPressReleaseTags;
|
||||
|
|
@ -122,6 +124,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
}
|
||||
|
||||
$this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($pr->placeholder_variant?->value)->value;
|
||||
|
||||
// KI-Grad vorbelegen (bereits deklariert); die verbindliche
|
||||
// Bestätigung muss bei jedem Submit erneut gesetzt werden.
|
||||
$this->aiTextDeclaration = $pr->ai_text_declaration?->value;
|
||||
}
|
||||
|
||||
#[On('placeholder-selected')]
|
||||
|
|
@ -248,6 +254,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
? $this->resolveScheduledAt()
|
||||
: null,
|
||||
'embargo_at' => null,
|
||||
...$this->aiDeclarationAttributes(),
|
||||
]);
|
||||
|
||||
$contentChanged = $pr->wasChanged(['title', 'text']);
|
||||
|
|
@ -287,6 +294,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
|
||||
public function save(bool $submitAfterSave = false): void
|
||||
{
|
||||
// KI-Kennzeichnung (Art. 50 KI-VO): Pflicht nur beim Einreichen —
|
||||
// zuerst prüfen, damit die Fehler direkt im Submit-Modal erscheinen.
|
||||
if ($submitAfterSave) {
|
||||
$this->validateAiDeclaration();
|
||||
}
|
||||
|
||||
$result = $this->persistEdit();
|
||||
|
||||
if (! $result) {
|
||||
|
|
@ -311,7 +324,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
|
||||
|
||||
return;
|
||||
} catch (BookingRequiredException|QuotaExceededException $e) {
|
||||
} catch (AiDeclarationRequiredException|BookingRequiredException|QuotaExceededException $e) {
|
||||
Flux::toast(
|
||||
heading: __('Gespeichert, aber nicht eingereicht'),
|
||||
text: $e->getMessage(),
|
||||
|
|
@ -487,7 +500,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
<div class="space-y-6 min-w-0">
|
||||
|
||||
{{-- 1) FIRMA-SELEKTOR --}}
|
||||
<section class="panel">
|
||||
<section class="panel @error('companyId') is-invalid @enderror" data-error-anchor="companyId">
|
||||
<div class="p-4 flex flex-wrap items-center gap-4">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">{{ __('Für Firma') }}</span>
|
||||
<div class="min-w-[260px]">
|
||||
|
|
@ -534,8 +547,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
<flux:error name="companyId" />
|
||||
</section>
|
||||
|
||||
{{-- 2) TITEL --}}
|
||||
<section class="panel">
|
||||
{{-- 2) KATEGORIE (Pflichtfeld — bewusst prominent vor der Headline) --}}
|
||||
<section class="panel @error('categoryId') is-invalid @enderror" data-error-anchor="categoryId">
|
||||
<div class="p-4">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">
|
||||
{{ __('Kategorie') }} <span class="req">*</span>
|
||||
</span>
|
||||
<div class="min-w-[260px]">
|
||||
<flux:select wire:model.live="categoryId">
|
||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||
@foreach ($categories as $cat)
|
||||
<option value="{{ $cat->id }}">{{ $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}
|
||||
</span>
|
||||
</div>
|
||||
<flux:error name="categoryId" class="mt-2" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- 3) TITEL --}}
|
||||
<section class="panel @error('title') is-invalid @enderror" data-error-anchor="title">
|
||||
<div class="p-5 pb-4">
|
||||
<div class="flex items-center justify-between mb-2 gap-4">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">
|
||||
|
|
@ -566,8 +602,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{{-- 3) SUBTITLE --}}
|
||||
<section class="panel">
|
||||
{{-- 4) SUBTITLE --}}
|
||||
<section class="panel @error('subtitle') is-invalid @enderror" data-error-anchor="subtitle">
|
||||
<div class="p-5 pb-4">
|
||||
<div class="flex items-center justify-between mb-2 gap-4">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">
|
||||
|
|
@ -593,8 +629,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{{-- 4) FLIESSTEXT --}}
|
||||
<section class="panel">
|
||||
{{-- 5) FLIESSTEXT --}}
|
||||
<section class="panel @error('text') is-invalid @enderror" data-error-anchor="text">
|
||||
<div class="p-5 pb-4">
|
||||
<div class="flex items-center justify-between mb-2 gap-4">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">
|
||||
|
|
@ -637,7 +673,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
</section>
|
||||
|
||||
@if ($coverIsPlaceholder)
|
||||
{{-- 5) TITELBILD-PLATZHALTER --}}
|
||||
{{-- 6) TITELBILD-PLATZHALTER --}}
|
||||
<section class="panel">
|
||||
<div class="p-5">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
|
|
@ -661,18 +697,18 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
<livewire:components.press-release-placeholder-picker :current="$placeholderVariant" />
|
||||
@endif
|
||||
|
||||
{{-- 6) MEDIEN — Image-Manager direkt eingebunden --}}
|
||||
{{-- 7) MEDIEN — Image-Manager direkt eingebunden --}}
|
||||
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
|
||||
|
||||
{{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT
|
||||
{{-- 7) ANHÄNGE — TEMPORÄR DEAKTIVIERT
|
||||
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung
|
||||
(Virus-/Malware-Scan, MIME-Validierung, Storage-Quoten).
|
||||
Wird in einer späteren Phase aktiviert.
|
||||
<livewire:components.press-release-attachments-manager :press-release-id="$id" :wire:key="'pr-attachments-'.$id" />
|
||||
--}}
|
||||
|
||||
{{-- 7) BOILERPLATE --}}
|
||||
<section class="panel">
|
||||
{{-- 8) BOILERPLATE --}}
|
||||
<section class="panel @error('boilerplateOverride') is-invalid @enderror" data-error-anchor="boilerplateOverride">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-3 gap-4">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">
|
||||
|
|
@ -737,6 +773,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
@endif
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<x-portal.pr-error-summary :errors="$errors" />
|
||||
|
||||
<x-portal.pr-presubmit-check :checks="$this->presubmitChecks" />
|
||||
|
||||
<flux:modal.trigger name="confirm-submit-review">
|
||||
|
|
@ -804,28 +842,6 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
</article>
|
||||
@endif
|
||||
|
||||
{{-- Kategorie (Pflichtfeld - prominent direkt unter Status) --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">
|
||||
{{ __('Kategorie') }}
|
||||
<span class="text-[color:var(--color-err)]">*</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<flux:field>
|
||||
<flux:select wire:model.live="categoryId">
|
||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||
@foreach ($categories as $cat)
|
||||
<option value="{{ $cat->id }}">{{ $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="categoryId" />
|
||||
<flux:description>{{ __('Pflichtfeld — steuert, in welcher Rubrik die PM erscheint.') }}</flux:description>
|
||||
</flux:field>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- Portal (Read-only, Badge in Portal-Farbe) --}}
|
||||
@php
|
||||
$portalPillClass = 'portal-pill';
|
||||
|
|
@ -855,7 +871,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
</article>
|
||||
|
||||
{{-- Pressekontakt --}}
|
||||
<article class="panel">
|
||||
<article class="panel @error('contactId') is-invalid @enderror" data-error-anchor="contactId">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Pressekontakt') }}</span>
|
||||
</div>
|
||||
|
|
@ -964,7 +980,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
</article>
|
||||
|
||||
{{-- Veröffentlichung --}}
|
||||
<article class="panel">
|
||||
<article class="panel @if ($errors->hasAny(['publishMode', 'scheduledDate', 'scheduledTime', 'scheduledAt'])) is-invalid @endif" data-error-anchor="scheduledAt">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Veröffentlichung') }}</span>
|
||||
</div>
|
||||
|
|
@ -1024,7 +1040,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
</article>
|
||||
|
||||
{{-- Weitere Felder --}}
|
||||
<article class="panel">
|
||||
<article class="panel @if ($errors->hasAny(['backlinkUrl', 'language', 'keywords'])) is-invalid @endif" data-error-anchor="backlinkUrl">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Weitere Felder') }}</span>
|
||||
</div>
|
||||
|
|
@ -1070,5 +1086,6 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
booking-action="saveDraftAndChooseBooking"
|
||||
:confirm-label="$currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & einreichen')"
|
||||
:quota-total="$quotaTotal"
|
||||
:quota-remaining="$quotaRemaining" />
|
||||
:quota-remaining="$quotaRemaining"
|
||||
:has-title-image="! $coverIsPlaceholder" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use App\Enums\Portal;
|
|||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use App\Services\PressRelease\AiDeclarationRequiredException;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\BookingRequiredException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
|
|
@ -17,6 +18,7 @@ use Livewire\Volt\Component;
|
|||
use Livewire\WithPagination;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class extends Component {
|
||||
use \App\Livewire\Concerns\InteractsWithAiDeclaration;
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
|
|
@ -87,25 +89,25 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
return;
|
||||
}
|
||||
|
||||
// KI-Kennzeichnung (Art. 50 KI-VO): Pflicht-Deklaration aus dem
|
||||
// Submit-Modal validieren und vor dem Einreichen persistieren.
|
||||
$this->validateAiDeclaration();
|
||||
$pr->update($this->aiDeclarationAttributes());
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->submitForReview($pr);
|
||||
Flux::toast(
|
||||
heading: __('Eingereicht'),
|
||||
text: __('Pressemitteilung zur Prüfung eingereicht.'),
|
||||
variant: 'success',
|
||||
);
|
||||
Flux::toast(heading: __('Eingereicht'), text: __('Pressemitteilung zur Prüfung eingereicht.'), variant: 'success');
|
||||
} catch (BlacklistViolationException $e) {
|
||||
Flux::toast(
|
||||
heading: __('Automatisch abgelehnt'),
|
||||
text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]),
|
||||
variant: 'danger',
|
||||
duration: 8000,
|
||||
);
|
||||
} catch (BookingRequiredException|QuotaExceededException $e) {
|
||||
Flux::toast(heading: __('Automatisch abgelehnt'), text: __('Unzulässiges Wort gefunden: ":word".', ['word' => $e->word]), variant: 'danger', duration: 8000);
|
||||
} catch (AiDeclarationRequiredException | BookingRequiredException | QuotaExceededException $e) {
|
||||
Flux::toast(text: $e->getMessage(), variant: 'warning', duration: 8000);
|
||||
} catch (\LogicException $e) {
|
||||
Flux::toast(text: $e->getMessage(), variant: 'danger');
|
||||
}
|
||||
|
||||
// Deklaration nicht zwischen PMs „verschleppen": Die Listen-Modals
|
||||
// teilen sich die Properties, daher nach jedem Versuch zurücksetzen.
|
||||
$this->reset('aiTextDeclaration', 'aiDeclarationConfirmed');
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
|
|
@ -119,7 +121,8 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
// auf die Status-Counts als auch die Liste angewandt, damit beide
|
||||
// deckungsgleich filtern.
|
||||
$applyCompanyFilter = function ($query) use ($selectedCompanyId): void {
|
||||
$query->when($selectedCompanyId !== null, fn($q) => $q->where('company_id', $selectedCompanyId))
|
||||
$query
|
||||
->when($selectedCompanyId !== null, fn($q) => $q->where('company_id', $selectedCompanyId))
|
||||
->when($selectedCompanyId === null && $this->companyFilter === 'assigned', fn($q) => $q->whereNotNull('company_id'))
|
||||
->when($selectedCompanyId === null && $this->companyFilter === 'unassigned', fn($q) => $q->whereNull('company_id'));
|
||||
};
|
||||
|
|
@ -139,9 +142,10 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
// beide UNION-Zweige gezogen (siehe PressRelease::accessiblePageFor()).
|
||||
$applyListFilters = function ($query) use ($applyCompanyFilter): void {
|
||||
$applyCompanyFilter($query);
|
||||
$query->when(filled($this->search), fn ($q) => $q->where('title', 'like', '%'.$this->search.'%'))
|
||||
->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter))
|
||||
->when($this->portalFilter !== 'all', fn ($q) => $q->where('portal', $this->portalFilter));
|
||||
$query
|
||||
->when(filled($this->search), fn($q) => $q->where('title', 'like', '%' . $this->search . '%'))
|
||||
->when($this->statusFilter !== 'all', fn($q) => $q->where('status', $this->statusFilter))
|
||||
->when($this->portalFilter !== 'all', fn($q) => $q->where('portal', $this->portalFilter));
|
||||
};
|
||||
|
||||
$prs = PressRelease::accessiblePageFor($user, $applyListFilters, $this->sortBy, $this->sortDir);
|
||||
|
|
@ -410,141 +414,141 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column class="w-[140px]">{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'title'" :direction="$sortDir"
|
||||
wire:click="sort('title')">
|
||||
{{ __('Titel') }}
|
||||
</flux:table.column>
|
||||
<flux:table.column class="w-[180px]">{{ __('Portal') }}</flux:table.column>
|
||||
<flux:table.column class="w-[180px]">{{ __('Firma') }}</flux:table.column>
|
||||
<flux:table.column class="w-[140px]" sortable :sorted="$sortBy === 'created_at'"
|
||||
:direction="$sortDir" wire:click="sort('created_at')">
|
||||
{{ __('Datum') }}
|
||||
</flux:table.column>
|
||||
<flux:table.column class="w-[80px]">{{ __('Aktionen') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'title'" :direction="$sortDir"
|
||||
wire:click="sort('title')">
|
||||
{{ __('Titel') }}
|
||||
</flux:table.column>
|
||||
<flux:table.column class="w-[180px]">{{ __('Portal') }}</flux:table.column>
|
||||
<flux:table.column class="w-[180px]">{{ __('Firma') }}</flux:table.column>
|
||||
<flux:table.column class="w-[140px]" sortable :sorted="$sortBy === 'created_at'"
|
||||
:direction="$sortDir" wire:click="sort('created_at')">
|
||||
{{ __('Datum') }}
|
||||
</flux:table.column>
|
||||
<flux:table.column class="w-[80px]">{{ __('Aktionen') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
@foreach ($pressReleases as $pr)
|
||||
@php
|
||||
$status = $pr->status->value;
|
||||
$rowClass = match ($status) {
|
||||
'review' => 'is-row-warn',
|
||||
'rejected' => 'is-row-err',
|
||||
default => '',
|
||||
};
|
||||
$badgeClass = $pr->status->badgeClass();
|
||||
$portal = $pr->portal?->value ?? 'both';
|
||||
$showPe = in_array($portal, ['presseecho', 'both'], true);
|
||||
$showBp = in_array($portal, ['businessportal24', 'both'], true);
|
||||
$dateSubLabel = match ($status) {
|
||||
'published' => __('veröffentlicht'),
|
||||
'review' => __('eingereicht'),
|
||||
'rejected' => __('abgelehnt'),
|
||||
'draft' => __('erstellt'),
|
||||
'archived' => __('archiviert'),
|
||||
default => __('erstellt'),
|
||||
};
|
||||
$primaryDate = match (true) {
|
||||
$status === 'published' && $pr->published_at => $pr->published_at,
|
||||
default => $pr->created_at,
|
||||
};
|
||||
@endphp
|
||||
@foreach ($pressReleases as $pr)
|
||||
@php
|
||||
$status = $pr->status->value;
|
||||
$rowClass = match ($status) {
|
||||
'review' => 'is-row-warn',
|
||||
'rejected' => 'is-row-err',
|
||||
default => '',
|
||||
};
|
||||
$badgeClass = $pr->status->badgeClass();
|
||||
$portal = $pr->portal?->value ?? 'both';
|
||||
$showPe = in_array($portal, ['presseecho', 'both'], true);
|
||||
$showBp = in_array($portal, ['businessportal24', 'both'], true);
|
||||
$dateSubLabel = match ($status) {
|
||||
'published' => __('veröffentlicht'),
|
||||
'review' => __('eingereicht'),
|
||||
'rejected' => __('abgelehnt'),
|
||||
'draft' => __('erstellt'),
|
||||
'archived' => __('archiviert'),
|
||||
default => __('erstellt'),
|
||||
};
|
||||
$primaryDate = match (true) {
|
||||
$status === 'published' && $pr->published_at => $pr->published_at,
|
||||
default => $pr->created_at,
|
||||
};
|
||||
@endphp
|
||||
|
||||
<flux:table.row wire:key="{{ $pr->id }}" class="{{ $rowClass }}">
|
||||
<flux:table.cell>
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<span class="badge {{ $badgeClass }} dot">{{ $pr->status->label() }}</span>
|
||||
@if ($status === 'draft')
|
||||
{{-- Öffnet das gemeinsame Einreichungs-Modal (rechtliche
|
||||
<flux:table.row wire:key="{{ $pr->id }}" class="{{ $rowClass }}">
|
||||
<flux:table.cell>
|
||||
<div class="flex items-center gap-1 flex-wrap">
|
||||
<span class="badge {{ $badgeClass }} dot">{{ $pr->status->label() }}</span>
|
||||
@if ($status === 'draft')
|
||||
{{-- Öffnet das gemeinsame Einreichungs-Modal (rechtliche
|
||||
Bestätigungen inkl. Erlöschensklausel) statt eines
|
||||
Schnell-Submits, der die Bestätigungen umgehen würde. --}}
|
||||
<flux:modal.trigger :name="'confirm-submit-'.$pr->id">
|
||||
<button type="button" class="inline-action"
|
||||
title="{{ __('Zur Inhaltsprüfung senden') }}">
|
||||
{{ __('Zur Prüfung →') }}
|
||||
</button>
|
||||
</flux:modal.trigger>
|
||||
<x-press-release-submit-modal
|
||||
:name="'confirm-submit-'.$pr->id"
|
||||
:action="'submitForReview('.$pr->id.')'"
|
||||
/>
|
||||
@endif
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
<a href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate
|
||||
class="block font-semibold text-[13.5px] leading-[1.35] text-[color:var(--color-ink)] hover:text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
|
||||
{{ $pr->title }}
|
||||
</a>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)] mt-0.5 leading-[1.4]">
|
||||
PM-{{ $pr->id }}
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@if ($showPe)
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
@endif
|
||||
@if ($showBp)
|
||||
<span class="portal-pill bp"><span
|
||||
class="pdot"></span>businessportal24</span>
|
||||
@endif
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
@if ($pr->company)
|
||||
<span class="text-[12.5px] text-[color:var(--color-ink-2)] font-medium">
|
||||
{{ $pr->company->name }}
|
||||
</span>
|
||||
@else
|
||||
<span
|
||||
class="text-[11.5px] text-[color:var(--color-ink-4)] italic">{{ __('— keine —') }}</span>
|
||||
<flux:modal.trigger :name="'confirm-submit-'.$pr->id">
|
||||
<button type="button" class="inline-action"
|
||||
title="{{ __('Zur Inhaltsprüfung senden') }}">
|
||||
{{ __('Zur Prüfung →') }}
|
||||
</button>
|
||||
</flux:modal.trigger>
|
||||
<x-press-release-submit-modal :name="'confirm-submit-' . $pr->id"
|
||||
:action="'submitForReview(' . $pr->id . ')'"
|
||||
:has-title-image="$pr->images_count > 0" />
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
<div class="font-mono text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
|
||||
{{ $primaryDate?->format('d.m.Y') }}
|
||||
</div>
|
||||
<div class="text-[10.5px] text-[color:var(--color-ink-4)] mt-0.5">
|
||||
{{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }}
|
||||
</div>
|
||||
@if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
|
||||
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
||||
<flux:icon.calendar variant="micro" class="size-3" />
|
||||
<span>{{ __('geplant') }} · {{ $pr->scheduledAtLocal()->format('d.m. H:i') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
|
||||
<div class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
||||
<flux:icon.lock-closed variant="micro" class="size-3" />
|
||||
<span>{{ __('Embargo bis') }} {{ $pr->embargoAtLocal()->format('d.m.') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<a href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate
|
||||
class="block font-semibold text-[13.5px] leading-[1.35] text-[color:var(--color-ink)] hover:text-[color:var(--color-hub)] hover:underline underline-offset-[3px] decoration-[color:var(--color-hub)]/40 hover:decoration-[color:var(--color-hub)]">
|
||||
{{ $pr->title }}
|
||||
</a>
|
||||
<div class="text-[11.5px] text-[color:var(--color-ink-3)] mt-0.5 leading-[1.4]">
|
||||
PM-{{ $pr->id }}
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="filled" icon="eye"
|
||||
href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate />
|
||||
@if (in_array($status, ['draft', 'rejected']))
|
||||
<flux:button size="sm" variant="filled" icon="pencil"
|
||||
href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate />
|
||||
@endif
|
||||
<flux:table.cell>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@if ($showPe)
|
||||
<span class="portal-pill pe"><span class="pdot"></span>presseecho</span>
|
||||
@endif
|
||||
@if ($showBp)
|
||||
<span class="portal-pill bp"><span class="pdot"></span>businessportal24</span>
|
||||
@endif
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
@if ($pr->company)
|
||||
<span class="text-[12.5px] text-[color:var(--color-ink-2)] font-medium">
|
||||
{{ $pr->company->name }}
|
||||
</span>
|
||||
@else
|
||||
<span
|
||||
class="text-[11.5px] text-[color:var(--color-ink-4)] italic">{{ __('— keine —') }}</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
<div class="font-mono text-[12px] text-[color:var(--color-ink-2)] tabular-nums">
|
||||
{{ $primaryDate?->format('d.m.Y') }}
|
||||
</div>
|
||||
<div class="text-[10.5px] text-[color:var(--color-ink-4)] mt-0.5">
|
||||
{{ $dateSubLabel }} · {{ $primaryDate?->format('H:i') }}
|
||||
</div>
|
||||
@if ($status === 'review' && $pr->scheduled_at && $pr->scheduled_at->isFuture())
|
||||
<div
|
||||
class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
||||
<flux:icon.calendar variant="micro" class="size-3" />
|
||||
<span>{{ __('geplant') }} ·
|
||||
{{ $pr->scheduledAtLocal()->format('d.m. H:i') }}</span>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table>
|
||||
@endif
|
||||
@if ($pr->embargo_at && $pr->embargo_at->isFuture())
|
||||
<div
|
||||
class="mt-1 inline-flex items-center gap-1 text-[10.5px] font-mono tabular-nums text-[color:var(--color-accent-deep)]">
|
||||
<flux:icon.lock-closed variant="micro" class="size-3" />
|
||||
<span>{{ __('Embargo bis') }} {{ $pr->embargoAtLocal()->format('d.m.') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
|
||||
<flux:table.cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="filled" icon="eye"
|
||||
href="{{ route('me.press-releases.show', $pr->id) }}" wire:navigate />
|
||||
@if (in_array($status, ['draft', 'rejected']))
|
||||
<flux:button size="sm" variant="filled" icon="pencil"
|
||||
href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate />
|
||||
@endif
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforeach
|
||||
</flux:table>
|
||||
<x-portal.table-pagination :paginator="$pressReleases" />
|
||||
@elseif ($hasAnyPR && $search !== '')
|
||||
{{-- Empty: Suche ohne Treffer --}}
|
||||
<x-portal.empty-state
|
||||
:title='__("Keine Treffer für „:term\"", ["term" => $search])'
|
||||
:subtitle="__('Wir konnten zu Ihrer Suche nichts finden. Prüfen Sie die Schreibweise oder schränken Sie den Suchbegriff ein.')"
|
||||
>
|
||||
<x-portal.empty-state :title='__("Keine Treffer für „:term\"", ["term" => $search])' :subtitle="__(
|
||||
'Wir konnten zu Ihrer Suche nichts finden. Prüfen Sie die Schreibweise oder schränken Sie den Suchbegriff ein.',
|
||||
)">
|
||||
<x-slot:icon>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="10.5" cy="10.5" r="6" stroke="currentColor" stroke-width="1.6" />
|
||||
|
|
@ -559,11 +563,9 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
</x-portal.empty-state>
|
||||
@elseif ($hasAnyPR && $hasAnyFilter)
|
||||
{{-- Empty: Filter ohne Treffer --}}
|
||||
<x-portal.empty-state
|
||||
variant="warm"
|
||||
:title="__('Keine Mitteilungen mit diesen Filtern')"
|
||||
:subtitle="__('Aktive Filter passen auf keine Einträge. Setzen Sie einen Filter zurück oder probieren Sie eine breitere Auswahl.')"
|
||||
>
|
||||
<x-portal.empty-state variant="warm" :title="__('Keine Mitteilungen mit diesen Filtern')" :subtitle="__(
|
||||
'Aktive Filter passen auf keine Einträge. Setzen Sie einen Filter zurück oder probieren Sie eine breitere Auswahl.',
|
||||
)">
|
||||
<x-slot:icon>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="1.8"
|
||||
|
|
@ -577,10 +579,9 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
</x-portal.empty-state>
|
||||
@else
|
||||
{{-- Empty: gar keine PMs überhaupt --}}
|
||||
<x-portal.empty-state
|
||||
:title="__('Noch keine Pressemitteilungen')"
|
||||
:subtitle="__('Starten Sie mit Ihrer ersten Mitteilung. Nach bestandener Inhaltsprüfung erscheint sie i. d. R. binnen 4 Stunden werktags auf beiden Portalen.')"
|
||||
>
|
||||
<x-portal.empty-state :title="__('Noch keine Pressemitteilungen')" :subtitle="__(
|
||||
'Starten Sie mit Ihrer ersten Mitteilung. Nach bestandener Inhaltsprüfung erscheint sie i. d. R. binnen wenigen Minuten auf den Portalen.',
|
||||
)">
|
||||
<x-slot:icon>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="4" width="14" height="16" stroke="currentColor" stroke-width="1.5" />
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use App\Models\SinglePurchase;
|
|||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Services\Billing\CreditPricingService;
|
||||
use App\Services\Billing\ProofPdfService;
|
||||
use App\Services\PressRelease\AiDeclarationRequiredException;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\BookingRequiredException;
|
||||
use App\Services\PressRelease\BoostService;
|
||||
|
|
@ -26,6 +27,8 @@ use Livewire\Volt\Component;
|
|||
|
||||
new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends Component
|
||||
{
|
||||
use \App\Livewire\Concerns\InteractsWithAiDeclaration;
|
||||
|
||||
#[Locked]
|
||||
public int $id;
|
||||
|
||||
|
|
@ -48,6 +51,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
$this->id = $id;
|
||||
$pr = $this->getMyPR();
|
||||
$this->authorize('view', $pr);
|
||||
|
||||
// KI-Grad vorbelegen (bereits deklariert); die verbindliche
|
||||
// Bestätigung muss bei jedem Submit erneut gesetzt werden.
|
||||
$this->aiTextDeclaration = $pr->ai_text_declaration?->value;
|
||||
}
|
||||
|
||||
public function submitForReview(): void
|
||||
|
|
@ -55,6 +62,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
$pr = $this->getMyPR();
|
||||
$this->authorize('submitForReview', $pr);
|
||||
|
||||
// KI-Kennzeichnung (Art. 50 KI-VO): Pflicht-Deklaration aus dem
|
||||
// Submit-Modal validieren und vor dem Einreichen persistieren.
|
||||
$this->validateAiDeclaration();
|
||||
$pr->update($this->aiDeclarationAttributes());
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->submitForReview($pr);
|
||||
} catch (BlacklistViolationException $e) {
|
||||
|
|
@ -68,7 +80,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
);
|
||||
|
||||
return;
|
||||
} catch (BookingRequiredException|QuotaExceededException $e) {
|
||||
} catch (AiDeclarationRequiredException|BookingRequiredException|QuotaExceededException $e) {
|
||||
Flux::modal('confirm-submit-review')->close();
|
||||
|
||||
Flux::toast(
|
||||
|
|
@ -854,13 +866,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<flux:icon.photo variant="micro" class="size-3.5" />
|
||||
<span>{{ __('Platzhalter-Titelbild — laden Sie im Editor ein eigenes Bild hoch.') }}</span>
|
||||
</div>
|
||||
@elseif ($titleImage && ($titleImage->copyright || $titleImage->is_ai_generated))
|
||||
@elseif ($titleImage && ($titleImage->copyright || $titleImage->is_ai_generated || $titleImage->ai_declaration?->publicLabel()))
|
||||
{{-- Bildnachweis + KI-Kennzeichnung (Art. 50 EU AI Act) --}}
|
||||
<div class="flex flex-wrap items-center gap-2 border-t border-[color:var(--color-bg-rule)] px-5 py-2.5 text-[12px] text-[color:var(--color-ink-3)]">
|
||||
@if ($titleImage->is_ai_generated)
|
||||
<span class="badge hub">{{ __('KI-generiert') }}</span>
|
||||
@endif
|
||||
<span>{{ $titleImage->copyright ?? __('Bild: KI-generiert') }}</span>
|
||||
@if ($aiImageHint = $titleImage->ai_declaration?->publicLabel())
|
||||
<span>· {{ $aiImageHint }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
|
|
@ -871,6 +886,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
{{-- KI-Label (Art. 50 KI-VO): erscheint so auch in der
|
||||
Veröffentlichung — am Anfang des Inhalts, in Einreicher-Stimme. --}}
|
||||
<x-portal.ai-public-label :label="$pr->aiTextPublicLabel()" class="mb-4 max-w-[760px]" />
|
||||
<div class="pr-content max-w-[760px]">
|
||||
{!! $pr->renderedText() !!}
|
||||
</div>
|
||||
|
|
@ -884,7 +902,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
action="submitForReview"
|
||||
:confirm-label="__('Veröffentlichung anfordern')"
|
||||
:quota-total="$quotaTotal"
|
||||
:quota-remaining="$quotaRemaining" />
|
||||
:quota-remaining="$quotaRemaining"
|
||||
:has-title-image="! $coverIsPlaceholder" />
|
||||
@endif
|
||||
|
||||
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\ImageLicenseType;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
|
|
@ -56,11 +57,29 @@ test('license type starts with an explicit placeholder before own photo option',
|
|||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->call('openUploadForm')
|
||||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||
->assertSet('newLicenseType', '')
|
||||
->assertSee('Bitte wählen')
|
||||
->assertSee('Eigene Aufnahme');
|
||||
});
|
||||
|
||||
test('detail fields only appear after an image was selected', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->call('openUploadForm')
|
||||
->assertSee('Bild hierher ziehen oder klicken')
|
||||
->assertDontSee('Herkunft & Bildnachweis')
|
||||
->assertDontSee('Personen & Rechte Dritter')
|
||||
->assertDontSee('Rechte bestätigt')
|
||||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||
->assertSee('Herkunft & Bildnachweis')
|
||||
->assertSee('Personen & Rechte Dritter')
|
||||
->assertSee('Rechte bestätigt');
|
||||
});
|
||||
|
||||
test('title image upload form is collapsed until requested', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
|
|
@ -180,6 +199,7 @@ test('valid upload stores license metadata and confirms rights', function () {
|
|||
->set('newCopyright', 'Foto: Jane Doe / Beispiel GmbH')
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->set('newLicenseType', ImageLicenseType::Own->value)
|
||||
->set('newAiDeclaration', AiImageDeclaration::Original->value)
|
||||
->set('newSourceUrl', 'https://example.com/source')
|
||||
->set('newPeopleRightsStatus', 'consent')
|
||||
->set('newPropertyRightsStatus', 'cleared')
|
||||
|
|
@ -228,6 +248,7 @@ test('valid cc upload stores license detail and license url', function () {
|
|||
->set('newAuthor', 'Jane Doe')
|
||||
->set('newCopyright', 'Foto: Jane Doe (CC BY 4.0)')
|
||||
->set('newLicenseType', ImageLicenseType::CreativeCommons->value)
|
||||
->set('newAiDeclaration', AiImageDeclaration::Original->value)
|
||||
->set('newLicenseDetail', 'cc_by')
|
||||
->set('newLicenseUrl', 'https://creativecommons.org/licenses/by/4.0/')
|
||||
->set('newPeopleRightsStatus', 'none')
|
||||
|
|
@ -244,7 +265,7 @@ test('valid cc upload stores license detail and license url', function () {
|
|||
expect($image->license_url)->toBe('https://creativecommons.org/licenses/by/4.0/');
|
||||
});
|
||||
|
||||
test('the public image credit is required', function () {
|
||||
test('the public image credit is required even when the suggestion was cleared', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
|
@ -253,6 +274,8 @@ test('the public image credit is required', function () {
|
|||
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->set('newLicenseType', ImageLicenseType::Own->value)
|
||||
// Der Vorschlag aus dem Urheber wird bewusst entfernt.
|
||||
->set('newCopyright', '')
|
||||
->set('newPeopleRightsStatus', 'none')
|
||||
->set('newPropertyRightsStatus', 'none')
|
||||
->set('newRightsConfirmed', true)
|
||||
|
|
@ -311,9 +334,42 @@ test('a valid ai generated upload stores tool and ai flag', function () {
|
|||
expect($image->license_type)->toBe(ImageLicenseType::AiGenerated);
|
||||
expect($image->license_detail)->toBe('Midjourney v7');
|
||||
expect($image->is_ai_generated)->toBeTrue();
|
||||
// Lizenztyp „KI-generiert" setzt die KI-Kennzeichnung automatisch.
|
||||
expect($image->ai_declaration)->toBe(AiImageDeclaration::AiGenerated);
|
||||
expect($image->copyright)->toBe('Bild: KI-generiert (Midjourney v7)');
|
||||
});
|
||||
|
||||
test('the copyright suggestion follows the author but respects manual input', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newLicenseType', ImageLicenseType::Own->value)
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->assertSet('newCopyright', 'Foto: Jane Doe')
|
||||
->set('newAuthor', 'John Smith')
|
||||
->assertSet('newCopyright', 'Foto: John Smith')
|
||||
->set('newCopyright', 'Eigener Nachweis')
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->assertSet('newCopyright', 'Eigener Nachweis');
|
||||
});
|
||||
|
||||
test('switching to ai replaces an automatically suggested author copyright', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LivewireVolt::test('components.press-release-images-manager', ['pressReleaseId' => $pr->id])
|
||||
->set('newLicenseType', ImageLicenseType::Own->value)
|
||||
->set('newAuthor', 'Jane Doe')
|
||||
->assertSet('newCopyright', 'Foto: Jane Doe')
|
||||
->set('newLicenseType', ImageLicenseType::AiGenerated->value)
|
||||
->assertSet('newCopyright', 'Bild: KI-generiert')
|
||||
->set('newLicenseType', ImageLicenseType::Own->value)
|
||||
->assertSet('newCopyright', 'Foto: Jane Doe');
|
||||
});
|
||||
|
||||
test('the ai copyright suggestion follows the tool but respects manual input', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = makeImageDraftOwner();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\AiTextDeclaration;
|
||||
use App\Enums\ImageLicenseType;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Plan;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
|
|
@ -58,6 +60,9 @@ test('submitting from the show modal moves the draft into review without consumi
|
|||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
// KI-Kennzeichnung (Art. 50 KI-VO) ist Pflicht im Submit-Modal.
|
||||
->set('aiTextDeclaration', AiTextDeclaration::None->value)
|
||||
->set('aiDeclarationConfirmed', true)
|
||||
->call('submitForReview')
|
||||
->assertHasNoErrors();
|
||||
|
||||
|
|
@ -66,6 +71,39 @@ test('submitting from the show modal moves the draft into review without consumi
|
|||
expect($customer->fresh()->press_release_quota_used_this_month)->toBe(0);
|
||||
});
|
||||
|
||||
test('the image rights confirmation is hidden while only the placeholder is active', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a();
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Ihre Bestätigungen')
|
||||
->assertDontSee('Alle Bildrechte sind geklärt.')
|
||||
->assertSeeHtml("' / 4'");
|
||||
});
|
||||
|
||||
test('the image rights confirmation appears once an own title image exists', function () {
|
||||
/** @var TestCase $this */
|
||||
['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a();
|
||||
|
||||
$pr->images()->create([
|
||||
'disk' => 'public',
|
||||
'path' => 'press-releases/'.$pr->id.'/images/title.jpg',
|
||||
'variants' => ['cover' => 'press-releases/'.$pr->id.'/images/title-cover.jpg'],
|
||||
'author' => 'Jane Doe',
|
||||
'license_type' => ImageLicenseType::Own->value,
|
||||
'rights_confirmed_at' => now(),
|
||||
'is_preview' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($customer);
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertSee('Alle Bildrechte sind geklärt.')
|
||||
->assertSeeHtml("' / 5'");
|
||||
});
|
||||
|
||||
test('the modal shows a booking notice instead of the submit flow when the gate is enforced', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('billing.enforce_booking', true);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue