images: Titelbild-Formular v2, Admin-Upload mit Kundenbestätigung, Entfernen mit Sofortlöschung (Bildkennzeichnung Block B)
- press-release-images-manager-v2 hinter IMAGE_RIGHTS_V2: drei Blöcke, Vorbelegung aus Metadaten, Bestätigung mit archiviertem Wortlaut, Bild-Bindung über Hash, Fokuspunkt und Letterbox, Zustände absent/prefilled/error - CustomerImageConfirmation: Magic-Link an den Kontoinhaber, Karte auf der Kundenseite, Kanal magic_link - PressReleaseImageRemover: nie veröffentlicht = alles sofort weg, veröffentlicht = Soft Delete, Original bleibt - ConversionSource::MailImageConfirmation Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
214aea8910
commit
3d3ddc2dc8
11 changed files with 1380 additions and 5 deletions
|
|
@ -23,6 +23,9 @@ enum ConversionSource: string
|
||||||
|
|
||||||
case ProfileClaim = 'profile_claim';
|
case ProfileClaim = 'profile_claim';
|
||||||
|
|
||||||
|
/** „Bild bestätigen" nach einem Admin-Upload (Bildkennzeichnung B.4, E10). */
|
||||||
|
case MailImageConfirmation = 'mail_image_confirmation';
|
||||||
|
|
||||||
case Organic = 'organic';
|
case Organic = 'organic';
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
|
|
@ -32,6 +35,7 @@ enum ConversionSource: string
|
||||||
self::MailReject => 'Ablehnungsmail',
|
self::MailReject => 'Ablehnungsmail',
|
||||||
self::MailReview => 'Prüfhinweis-Mail',
|
self::MailReview => 'Prüfhinweis-Mail',
|
||||||
self::ProfileClaim => 'Hinweis auf der Newsroom-Seite',
|
self::ProfileClaim => 'Hinweis auf der Newsroom-Seite',
|
||||||
|
self::MailImageConfirmation => 'Mail „Bild bestätigen"',
|
||||||
self::Organic => 'ohne zugeordnete Herkunft',
|
self::Organic => 'ohne zugeordnete Herkunft',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
app/Mail/PressReleaseImageConfirmationRequested.php
Normal file
44
app/Mail/PressReleaseImageConfirmationRequested.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\PressReleaseImage;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Bild bestätigen" (Decision-Update E10): Ein Admin hat im Auftrag des
|
||||||
|
* Kunden ein Titelbild hochgeladen. Veröffentlicht wird es erst, wenn der
|
||||||
|
* Kontoinhaber die Rechtebestätigung selbst abgibt — der Magic-Link führt
|
||||||
|
* direkt auf die Meldung im Konto.
|
||||||
|
*/
|
||||||
|
class PressReleaseImageConfirmationRequested extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly PressRelease $pressRelease,
|
||||||
|
public readonly PressReleaseImage $image,
|
||||||
|
public readonly string $confirmationUrl,
|
||||||
|
public readonly Carbon $expiresAt,
|
||||||
|
public readonly ?string $recipientName,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Bitte bestätigen: Titelbild für Ihre Pressemitteilung',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(view: 'emails.press-release.image-confirmation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease\Images;
|
||||||
|
|
||||||
|
use App\Enums\ConfirmationChannel;
|
||||||
|
use App\Enums\ConversionSource;
|
||||||
|
use App\Mail\PressReleaseImageConfirmationRequested;
|
||||||
|
use App\Models\ImageRightsConfirmation;
|
||||||
|
use App\Models\PressReleaseImage;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\MagicLinkGenerator;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kundenbestätigung für Admin-Uploads (Decision-Update E10, Block B.4):
|
||||||
|
* Der Kontoinhaber der Meldung bekommt einen Magic-Link „Bild bestätigen",
|
||||||
|
* der ihn auf die Meldung im Konto führt. Dort gibt er dieselbe Bestätigung
|
||||||
|
* ab wie beim eigenen Upload; das Protokoll trägt den Kanal `magic_link`.
|
||||||
|
*/
|
||||||
|
class CustomerImageConfirmation
|
||||||
|
{
|
||||||
|
public const LINK_TTL_MINUTES = 60 * 24 * 3;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly MagicLinkGenerator $magicLinks,
|
||||||
|
private readonly PressReleaseImageUploader $uploader,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link erzeugen und Mail an den Kontoinhaber schicken. Erneut aufrufbar;
|
||||||
|
* ein neuer Link entwertet den alten (MagicLinkGenerator).
|
||||||
|
*/
|
||||||
|
public function request(PressReleaseImage $image, ?string $requestedIp = null): bool
|
||||||
|
{
|
||||||
|
$pressRelease = $image->pressRelease;
|
||||||
|
$owner = $pressRelease?->user;
|
||||||
|
|
||||||
|
if ($pressRelease === null || $owner === null || blank($owner->email)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = $this->magicLinks->createLoginLink(
|
||||||
|
$owner,
|
||||||
|
$requestedIp,
|
||||||
|
self::LINK_TTL_MINUTES,
|
||||||
|
ConversionSource::MailImageConfirmation,
|
||||||
|
[
|
||||||
|
'company_id' => $pressRelease->company_id,
|
||||||
|
'press_release_id' => $pressRelease->id,
|
||||||
|
'image_confirmation' => $image->id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($image->customer_confirmation_requested_at === null) {
|
||||||
|
$image->forceFill(['customer_confirmation_requested_at' => now()])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
Mail::to($owner->email)->queue(
|
||||||
|
(new PressReleaseImageConfirmationRequested(
|
||||||
|
$pressRelease,
|
||||||
|
$image,
|
||||||
|
route('magic-links.consume', ['token' => $link['plain_token']]),
|
||||||
|
$link['expires_at'],
|
||||||
|
$owner->name,
|
||||||
|
))->afterCommit(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestätigung des Kontoinhabers im Konto (Kanal magic_link).
|
||||||
|
*/
|
||||||
|
public function confirm(PressReleaseImage $image, User $customer, ?string $legalTextKey, ?string $ip, ?string $userAgent, ?string $agbUrl = null): ImageRightsConfirmation
|
||||||
|
{
|
||||||
|
return $this->uploader->confirmExisting(
|
||||||
|
$image,
|
||||||
|
new ImageUploadActor($customer, ConfirmationChannel::MagicLink, null, $ip, $userAgent),
|
||||||
|
$legalTextKey,
|
||||||
|
$agbUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\PressRelease\Images;
|
||||||
|
|
||||||
|
use App\Models\PressReleaseImage;
|
||||||
|
use App\Services\Image\ImageService;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt ein Titelbild (Bildkennzeichnung E.3, Decision-Update E4):
|
||||||
|
*
|
||||||
|
* - nie veröffentlicht: Derivate, Original, Snapshot und Bestätigung sofort
|
||||||
|
* und vollständig weg, die Zeile wird hart gelöscht;
|
||||||
|
* - veröffentlicht: Derivate weg, Zeile weich gelöscht; Original, Snapshot
|
||||||
|
* und Bestätigung bleiben für das Beweisarchiv (Block F übernimmt die
|
||||||
|
* Überführung und die Frist).
|
||||||
|
*
|
||||||
|
* Bestand ohne `rules_version` hat weder Original noch Protokoll und wird
|
||||||
|
* wie bisher behandelt (Derivate weg, Soft Delete).
|
||||||
|
*/
|
||||||
|
class PressReleaseImageRemover
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ImageService $imageService) {}
|
||||||
|
|
||||||
|
public function remove(PressReleaseImage $image): void
|
||||||
|
{
|
||||||
|
$this->imageService->deletePressReleaseImage($image->disk, $image->path, $image->variants);
|
||||||
|
|
||||||
|
if ($image->wasEverPublished() || $image->isLegacyRules()) {
|
||||||
|
$image->delete();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($image): void {
|
||||||
|
foreach ($image->rightsConfirmations()->get() as $confirmation) {
|
||||||
|
$confirmation->purgeForUnpublished();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($image->original_path !== null) {
|
||||||
|
Storage::disk($image->original_disk ?? (string) config('images.originals_disk'))->delete($image->original_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($image->published_snapshot_path !== null) {
|
||||||
|
Storage::disk($image->original_disk ?? (string) config('images.originals_disk'))->delete($image->published_snapshot_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$image->forceDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -182,6 +182,34 @@ class PressReleaseImageUploader
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachträgliche Bestätigung eines bestehenden Bildes durch den
|
||||||
|
* Kontoinhaber (Admin-Upload, Decision-Update E10) — dieselbe Prüfung
|
||||||
|
* der Textkennung, dasselbe Protokoll, gebunden an den aktuellen Hash.
|
||||||
|
*/
|
||||||
|
public function confirmExisting(PressReleaseImage $image, ImageUploadActor $actor, ?string $legalTextKey, ?string $agbUrl = null): ImageRightsConfirmation
|
||||||
|
{
|
||||||
|
if (! $actor->confirmsImmediately()) {
|
||||||
|
throw new ImageUploadException(__('Die Bestätigung muss vom Kontoinhaber abgegeben werden.'), 'rights_confirmed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$legalText = $this->resolveLegalText(
|
||||||
|
new ImageSubmission(copyright: (string) $image->copyright, aiDeclaration: $image->ai_declaration, rightsConfirmed: true, legalTextKey: $legalTextKey),
|
||||||
|
$actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($legalText === null || $image->original_sha256 === null) {
|
||||||
|
throw new ImageUploadException(__('Dieses Bild kann nicht nachträglich bestätigt werden.'), 'rights_confirmed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($image, $actor, $legalText, $agbUrl): ImageRightsConfirmation {
|
||||||
|
$confirmation = $this->recordConfirmation($image, $image->pressRelease, $actor, $legalText, $agbUrl);
|
||||||
|
$image->forceFill(['rights_confirmed_at' => now()])->save();
|
||||||
|
|
||||||
|
return $confirmation;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Portalregel (A.4, F11): Ein vertrauenswürdig geprüfter Befund setzt
|
* Portalregel (A.4, F11): Ein vertrauenswürdig geprüfter Befund setzt
|
||||||
* die Mindestkennzeichnung, die Kundenangabe darf nur verschärfen.
|
* die Mindestkennzeichnung, die Kundenangabe darf nur verschärfen.
|
||||||
|
|
@ -255,11 +283,11 @@ class PressReleaseImageUploader
|
||||||
return $current;
|
return $current;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recordConfirmation(PressReleaseImage $image, PressRelease $pressRelease, ImageUploadActor $actor, LegalText $legalText, ?string $agbUrl): void
|
private function recordConfirmation(PressReleaseImage $image, PressRelease $pressRelease, ImageUploadActor $actor, LegalText $legalText, ?string $agbUrl): ImageRightsConfirmation
|
||||||
{
|
{
|
||||||
$variables = $this->textRenderer->variables($agbUrl);
|
$variables = $this->textRenderer->variables($agbUrl);
|
||||||
|
|
||||||
ImageRightsConfirmation::query()->create([
|
return ImageRightsConfirmation::query()->create([
|
||||||
'subject_type' => $image->getMorphClass(),
|
'subject_type' => $image->getMorphClass(),
|
||||||
'subject_id' => $image->getKey(),
|
'subject_id' => $image->getKey(),
|
||||||
'press_release_id' => $pressRelease->getKey(),
|
'press_release_id' => $pressRelease->getKey(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<x-emails.layout title="Bitte bestätigen Sie das Titelbild" preheader="Für Ihre Pressemitteilung wurde ein Titelbild hinterlegt. Es wird erst veröffentlicht, wenn Sie die Bildrechte bestätigen.">
|
||||||
|
<p>{{ filled($recipientName) ? 'Hallo '.$recipientName.',' : 'Guten Tag,' }}</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
für Ihre Pressemitteilung wurde von unserem Team ein Titelbild hinterlegt.
|
||||||
|
Damit es veröffentlicht werden kann, brauchen wir Ihre Bestätigung, dass Sie
|
||||||
|
zur Veröffentlichung des Bildes berechtigt sind und die Angaben dazu stimmen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="title">{{ $pressRelease->title }}</p>
|
||||||
|
|
||||||
|
<dl class="meta">
|
||||||
|
<dt>Bildnachweis</dt>
|
||||||
|
<dd>{{ $image->copyright ?: '—' }}</dd>
|
||||||
|
<dt>KI-Kennzeichnung</dt>
|
||||||
|
<dd>{{ $image->ai_declaration?->label() ?? 'noch offen' }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<x-emails.button :url="$confirmationUrl">Bild ansehen und bestätigen</x-emails.button>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Der Link meldet Sie direkt in Ihrem Konto an und ist gültig bis
|
||||||
|
<strong>{{ $expiresAt->format('d.m.Y H:i') }} Uhr</strong>. Danach können Sie
|
||||||
|
sich jederzeit regulär anmelden und die Bestätigung auf der Seite der
|
||||||
|
Pressemitteilung nachholen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<p>
|
||||||
|
Ohne Ihre Bestätigung bleibt die Pressemitteilung mit dem Platzhalter-Bild.
|
||||||
|
Wenn das Bild nicht von Ihnen stammt oder die Angaben nicht stimmen,
|
||||||
|
antworten Sie bitte auf diese E-Mail.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</x-emails.layout>
|
||||||
|
|
@ -691,7 +691,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{-- 5) MEDIEN --}}
|
{{-- 5) MEDIEN --}}
|
||||||
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
|
@if (config('images.rights_v2'))
|
||||||
|
<livewire:components.press-release-images-manager-v2 :press-release-id="$id" :wire:key="'pr-images-v2-'.$id" />
|
||||||
|
@else
|
||||||
|
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT
|
{{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT
|
||||||
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung
|
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,738 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\AiImageDeclaration;
|
||||||
|
use App\Enums\ImageProvenanceStatus;
|
||||||
|
use App\Enums\PressReleaseStatus;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\PressReleaseImage;
|
||||||
|
use App\Scopes\PortalScope;
|
||||||
|
use App\Services\Image\ImageRightsTextRenderer;
|
||||||
|
use App\Services\Image\ImageService;
|
||||||
|
use App\Services\PressRelease\Images\CustomerImageConfirmation;
|
||||||
|
use App\Services\PressRelease\Images\ImageSubmission;
|
||||||
|
use App\Services\PressRelease\Images\ImageUploadActor;
|
||||||
|
use App\Services\PressRelease\Images\ImageUploadException;
|
||||||
|
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||||
|
use App\Services\PressRelease\Images\PressReleaseImageUploader;
|
||||||
|
use Flux\Flux;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Titelbild-Manager, Formular v2 (Bildkennzeichnung Block B, Decision-Update
|
||||||
|
* 22.09.2026). Drei Blöcke: Bild, Angaben (Nachweis + KI-Kennzeichnung),
|
||||||
|
* Bestätigung. Herkunftsdaten der Datei belegen vor, sperren aber nichts
|
||||||
|
* (Phase 1). Der Uploader bestimmt alle effektiven Werte serverseitig; die
|
||||||
|
* hier gesperrten Properties sind nur der Merker für die Datei-Bindung (B.2).
|
||||||
|
*
|
||||||
|
* Admin-Upload: keine Bestätigung durch den Admin, das Bild wartet auf die
|
||||||
|
* Kundenbestätigung per Magic-Link (E10).
|
||||||
|
*/
|
||||||
|
new class extends Component {
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public int $pressReleaseId;
|
||||||
|
|
||||||
|
public $newImage = null;
|
||||||
|
|
||||||
|
public string $newCopyright = '';
|
||||||
|
|
||||||
|
public string $newAiDeclaration = '';
|
||||||
|
|
||||||
|
public string $newTitle = '';
|
||||||
|
|
||||||
|
public string $newDescription = '';
|
||||||
|
|
||||||
|
public string $newLicenseUrl = '';
|
||||||
|
|
||||||
|
public string $newSourceUrl = '';
|
||||||
|
|
||||||
|
public bool $newRightsConfirmed = false;
|
||||||
|
|
||||||
|
public bool $noCrop = false;
|
||||||
|
|
||||||
|
/** Fokuspunkt als "x,y" in Prozent (Neun-Punkte-Wahl, H.3). */
|
||||||
|
public string $focus = '50,50';
|
||||||
|
|
||||||
|
public bool $isUploadFormOpen = false;
|
||||||
|
|
||||||
|
public bool $showLegalDetails = false;
|
||||||
|
|
||||||
|
/** Zuletzt automatisch vorgeschlagener Nachweis (manuelle Eingaben bleiben). */
|
||||||
|
public string $autoCopyright = '';
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?string $provenanceStatus = null;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?string $provenanceSha = null;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?string $creditSuggestion = null;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public ?string $aiSuggestion = null;
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public bool $enhancementHint = false;
|
||||||
|
|
||||||
|
public function mount(int $pressReleaseId): void
|
||||||
|
{
|
||||||
|
$this->pressReleaseId = $pressReleaseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openUploadForm(): void
|
||||||
|
{
|
||||||
|
$this->isUploadFormOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedIsUploadFormOpen(bool $isOpen): void
|
||||||
|
{
|
||||||
|
if (!$isOpen) {
|
||||||
|
$this->resetUploadForm();
|
||||||
|
$this->resetErrorBag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach der Dateiauswahl: Grenzen prüfen, Herkunft lesen, Formular
|
||||||
|
* vorbelegen. Ein Dateiwechsel setzt Vorbelegung und Schalter zurück (B.2).
|
||||||
|
*/
|
||||||
|
public function updatedNewImage(PressReleaseImageUploader $uploader): void
|
||||||
|
{
|
||||||
|
$this->resetProvenance();
|
||||||
|
$this->newRightsConfirmed = false;
|
||||||
|
$this->resetErrorBag();
|
||||||
|
|
||||||
|
if (!$this->newImage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validateOnly('newImage', ['newImage' => $this->imageRules()]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $uploader->preflight($this->newImage);
|
||||||
|
} catch (ImageUploadException $exception) {
|
||||||
|
$this->addError('newImage', $exception->getMessage());
|
||||||
|
$this->reset('newImage');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->provenanceStatus = $result->status->value;
|
||||||
|
$this->provenanceSha = $result->fileSha256;
|
||||||
|
$this->creditSuggestion = $result->creditSuggestion();
|
||||||
|
$this->aiSuggestion = $result->suggestedAiDeclaration()?->value;
|
||||||
|
$this->enhancementHint = (bool) $result->derived['enhancement_hint'];
|
||||||
|
|
||||||
|
// Nachweis vorbelegen: aus den Metadaten, sonst Firmenname — nur,
|
||||||
|
// solange der Nutzer nichts Eigenes eingetragen hat.
|
||||||
|
$suggested = $this->creditSuggestion ?? $this->companyName();
|
||||||
|
|
||||||
|
if ($this->newCopyright === '' || $this->newCopyright === $this->autoCopyright) {
|
||||||
|
$this->newCopyright = $suggested;
|
||||||
|
$this->autoCopyright = $suggested;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->newAiDeclaration === '' && $this->aiSuggestion !== null) {
|
||||||
|
$this->newAiDeclaration = $this->aiSuggestion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeUploadForm(): void
|
||||||
|
{
|
||||||
|
$this->resetUploadForm();
|
||||||
|
$this->resetErrorBag();
|
||||||
|
|
||||||
|
$this->isUploadFormOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeNewImage(): void
|
||||||
|
{
|
||||||
|
$this->reset('newImage');
|
||||||
|
$this->resetProvenance();
|
||||||
|
$this->newRightsConfirmed = false;
|
||||||
|
$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 saveImage(PressReleaseImageUploader $uploader, ImageRightsTextRenderer $texts, CustomerImageConfirmation $customerConfirmation): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isAdmin = $this->actsAsAdmin();
|
||||||
|
|
||||||
|
$this->validate(
|
||||||
|
[
|
||||||
|
'newImage' => $this->imageRules(),
|
||||||
|
'newCopyright' => ['required', 'string', 'max:255'],
|
||||||
|
'newAiDeclaration' => ['required', Rule::enum(AiImageDeclaration::class)],
|
||||||
|
'newTitle' => ['nullable', 'string', 'max:120'],
|
||||||
|
'newDescription' => ['nullable', 'string', 'max:500'],
|
||||||
|
'newLicenseUrl' => ['nullable', 'url:http,https', 'max:2048'],
|
||||||
|
'newSourceUrl' => ['nullable', 'url:http,https', 'max:2048'],
|
||||||
|
'newRightsConfirmed' => [$isAdmin ? 'nullable' : 'accepted'],
|
||||||
|
'focus' => ['required', Rule::in(array_keys($this->focusOptions()))],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'newCopyright.required' => __('Bitte geben Sie an, wer das Bild erstellt hat oder die Rechte hält.'),
|
||||||
|
'newAiDeclaration.required' => __('Bitte wählen Sie aus, ob das Bild mit KI erstellt oder bearbeitet wurde.'),
|
||||||
|
'newRightsConfirmed.accepted' => __('Die Bestätigung ist erforderlich, um das Bild zu veröffentlichen.'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$legalText = $texts->current();
|
||||||
|
[$focusX, $focusY] = array_map('intval', explode(',', $this->focus));
|
||||||
|
|
||||||
|
$submission = new ImageSubmission(
|
||||||
|
copyright: trim($this->newCopyright),
|
||||||
|
aiDeclaration: AiImageDeclaration::from($this->newAiDeclaration),
|
||||||
|
rightsConfirmed: $this->newRightsConfirmed,
|
||||||
|
legalTextKey: $legalText?->key,
|
||||||
|
title: $this->newTitle ?: null,
|
||||||
|
description: $this->newDescription ?: null,
|
||||||
|
licenseUrl: $this->newLicenseUrl ?: null,
|
||||||
|
sourceUrl: $this->newSourceUrl ?: null,
|
||||||
|
focus: ['x' => $focusX, 'y' => $focusY],
|
||||||
|
noCrop: $this->noCrop,
|
||||||
|
creditFromSuggestion: $this->creditSuggestion !== null && trim($this->newCopyright) === $this->creditSuggestion,
|
||||||
|
);
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
$actor = $isAdmin
|
||||||
|
? ImageUploadActor::admin($user, request()->ip(), request()->userAgent())
|
||||||
|
: ImageUploadActor::form($user, request()->ip(), request()->userAgent());
|
||||||
|
|
||||||
|
try {
|
||||||
|
$image = $uploader->store(
|
||||||
|
$this->newImage,
|
||||||
|
$pressRelease,
|
||||||
|
$submission,
|
||||||
|
$actor,
|
||||||
|
expectedSha256: $this->provenanceSha,
|
||||||
|
agbUrl: $this->agbUrl(),
|
||||||
|
);
|
||||||
|
} catch (ImageUploadException $exception) {
|
||||||
|
$this->addError($exception->field === 'rights_confirmed' ? 'newRightsConfirmed' : 'newImage', $exception->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isAdmin) {
|
||||||
|
$customerConfirmation->request($image, request()->ip());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->resetUploadForm();
|
||||||
|
$this->isUploadFormOpen = false;
|
||||||
|
|
||||||
|
$this->dispatch('title-image-changed');
|
||||||
|
|
||||||
|
Flux::toast(
|
||||||
|
text: $isAdmin
|
||||||
|
? __('Titelbild hochgeladen. Der Kunde wurde per E-Mail um die Bestätigung der Bildrechte gebeten.')
|
||||||
|
: __('Titelbild hochgeladen.'),
|
||||||
|
variant: 'success',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin: Bestätigungslink erneut an den Kontoinhaber schicken.
|
||||||
|
*/
|
||||||
|
public function resendConfirmationRequest(CustomerImageConfirmation $customerConfirmation): void
|
||||||
|
{
|
||||||
|
$pressRelease = $this->getPressRelease();
|
||||||
|
$this->authorize('update', $pressRelease);
|
||||||
|
|
||||||
|
$image = $this->titleImageFor($pressRelease);
|
||||||
|
|
||||||
|
if ($image === null || !$image->awaitsCustomerConfirmation() || !$this->actsAsAdmin()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sent = $customerConfirmation->request($image, request()->ip());
|
||||||
|
|
||||||
|
Flux::toast(
|
||||||
|
text: $sent ? __('Bestätigungslink erneut gesendet.') : __('Kein Empfänger für den Bestätigungslink hinterlegt.'),
|
||||||
|
variant: $sent ? 'success' : 'warning',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(int $imageId, PressReleaseImageRemover $remover): void
|
||||||
|
{
|
||||||
|
$pressRelease = $this->getPressRelease();
|
||||||
|
$this->authorize('update', $pressRelease);
|
||||||
|
|
||||||
|
if (!$this->canChangeImages($pressRelease)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = $pressRelease->images()->whereKey($imageId)->first();
|
||||||
|
|
||||||
|
if (!$image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remover->remove($image);
|
||||||
|
|
||||||
|
$this->dispatch('title-image-changed');
|
||||||
|
|
||||||
|
Flux::toast(text: __('Titelbild entfernt.'), variant: 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function with(ImageRightsTextRenderer $texts): array
|
||||||
|
{
|
||||||
|
$pressRelease = $this->getPressRelease();
|
||||||
|
$titleImage = $this->titleImageFor($pressRelease);
|
||||||
|
$legalText = $texts->current();
|
||||||
|
$agbUrl = $this->agbUrl();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'titleImage' => $titleImage,
|
||||||
|
'latestConfirmation' => $titleImage?->latestRightsConfirmation,
|
||||||
|
'awaitsCustomerConfirmation' => $titleImage?->awaitsCustomerConfirmation() ?? false,
|
||||||
|
'canEdit' => auth()->user()?->can('update', $pressRelease) === true && $this->canChangeImages($pressRelease),
|
||||||
|
'isAdminActor' => $this->actsAsAdmin(),
|
||||||
|
'companyName' => $this->companyName(),
|
||||||
|
'legalTextKey' => $legalText?->key,
|
||||||
|
'legalLong' => $legalText ? $texts->renderLong($legalText, $agbUrl) : null,
|
||||||
|
'legalShort' => $legalText ? $texts->renderShort($legalText, $agbUrl) : null,
|
||||||
|
'provenanceState' => $this->provenanceState(),
|
||||||
|
'aiOptions' => AiImageDeclaration::cases(),
|
||||||
|
'focusOptions' => $this->focusOptions(),
|
||||||
|
'maxMb' => (int) round((int) config('images.max_bytes') / 1024 / 1024),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function imageRules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'required',
|
||||||
|
'image',
|
||||||
|
'mimes:jpeg,jpg,png,webp',
|
||||||
|
'max:' . (int) ((int) config('images.max_bytes') / 1024),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustand des Prüfhinweises im Formular (B.1): B keine Daten, C
|
||||||
|
* vorbelegt, D nicht prüfbar. Zustand A (geprüfte Signatur) kommt mit
|
||||||
|
* Phase 2.
|
||||||
|
*
|
||||||
|
* @return array{key: string, title: string, text: string, tone: string}|null
|
||||||
|
*/
|
||||||
|
private function provenanceState(): ?array
|
||||||
|
{
|
||||||
|
if ($this->provenanceStatus === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (ImageProvenanceStatus::from($this->provenanceStatus)) {
|
||||||
|
ImageProvenanceStatus::Error => [
|
||||||
|
'key' => 'error',
|
||||||
|
'title' => __('Herkunftsdaten konnten nicht geprüft werden.'),
|
||||||
|
'text' => __('Bitte machen Sie die Angaben selbst. Ihr Upload wird dadurch nicht blockiert.'),
|
||||||
|
'tone' => 'amber',
|
||||||
|
],
|
||||||
|
ImageProvenanceStatus::UnsignedMetadata => [
|
||||||
|
'key' => 'prefilled',
|
||||||
|
'title' => __('Angaben aus den Metadaten der Datei vorbelegt.'),
|
||||||
|
'text' => $this->aiSuggestion !== null
|
||||||
|
? __('Die Datei nennt als Quelle „:type". Sie können die Auswahl ändern.', ['type' => AiImageDeclaration::from($this->aiSuggestion)->label()])
|
||||||
|
: ($this->enhancementHint
|
||||||
|
? __('Die Datei ist als algorithmisch nachbearbeitet gekennzeichnet (z. B. Schärfung). Das allein ist keine KI-Bearbeitung.')
|
||||||
|
: __('Bitte prüfen Sie den vorgeschlagenen Bildnachweis.')),
|
||||||
|
'tone' => 'zinc',
|
||||||
|
],
|
||||||
|
ImageProvenanceStatus::ValidatedTrusted => [
|
||||||
|
'key' => 'verified',
|
||||||
|
'title' => __('Herkunftsdaten erkannt (Content Credentials).'),
|
||||||
|
'text' => __('Die Kennzeichnung ist fest hinterlegt und kann nicht abgeschwächt werden.'),
|
||||||
|
'tone' => 'green',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'key' => 'absent',
|
||||||
|
'title' => __('Keine Herkunftsdaten in der Datei gefunden.'),
|
||||||
|
'text' => __('Bitte machen Sie die Angaben selbst.'),
|
||||||
|
'tone' => 'zinc',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function focusOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'0,0' => __('oben links'),
|
||||||
|
'50,0' => __('oben'),
|
||||||
|
'100,0' => __('oben rechts'),
|
||||||
|
'0,50' => __('links'),
|
||||||
|
'50,50' => __('Mitte'),
|
||||||
|
'100,50' => __('rechts'),
|
||||||
|
'0,100' => __('unten links'),
|
||||||
|
'50,100' => __('unten'),
|
||||||
|
'100,100' => __('unten rechts'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function agbUrl(): string
|
||||||
|
{
|
||||||
|
return route('agb', ['edition' => 'de']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function companyName(): string
|
||||||
|
{
|
||||||
|
return (string) ($this->getPressRelease()->company()->withoutGlobalScope(PortalScope::class)->value('name') ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function actsAsAdmin(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $user !== null && $user->canAccessAdmin() && $this->getPressRelease()->user_id !== $user->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
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()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resetProvenance(): void
|
||||||
|
{
|
||||||
|
$this->provenanceStatus = null;
|
||||||
|
$this->provenanceSha = null;
|
||||||
|
$this->creditSuggestion = null;
|
||||||
|
$this->aiSuggestion = null;
|
||||||
|
$this->enhancementHint = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resetUploadForm(): void
|
||||||
|
{
|
||||||
|
$this->reset(['newImage', 'newCopyright', 'newAiDeclaration', 'newTitle', 'newDescription', 'newLicenseUrl', 'newSourceUrl', 'newRightsConfirmed', 'noCrop', 'focus', 'showLegalDetails', 'autoCopyright']);
|
||||||
|
$this->resetProvenance();
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<flux:card>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<flux:heading size="md">{{ __('Titelbild') }}</flux:heading>
|
||||||
|
<flux:badge color="{{ $titleImage ? ($awaitsCustomerConfirmation ? 'amber' : 'green') : 'zinc' }}" size="sm">
|
||||||
|
{{ $titleImage ? ($awaitsCustomerConfirmation ? __('wartet auf Kundenbestätigung') : __('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->copyright)
|
||||||
|
<p class="m-0 truncate text-xs text-zinc-500" title="{{ $titleImage->copyright }}">
|
||||||
|
{{ __('Bildnachweis: :copyright', ['copyright' => $titleImage->copyright]) }}
|
||||||
|
</p>
|
||||||
|
@elseif ($titleImage->author)
|
||||||
|
<p class="m-0 truncate text-xs text-zinc-500">© {{ $titleImage->author }}</p>
|
||||||
|
@endif
|
||||||
|
<div class="flex flex-wrap items-center gap-1 text-xs text-zinc-400">
|
||||||
|
@if ($aiBadge = $titleImage->ai_declaration?->publicLabel() ?? ($titleImage->is_ai_generated ? __('KI-generiert') : null))
|
||||||
|
<flux:badge color="purple" size="xs">{{ $aiBadge }}</flux:badge>
|
||||||
|
@endif
|
||||||
|
@if ($titleImage->ai_declaration_source)
|
||||||
|
<span>{{ __('Kennzeichnung: :source', ['source' => $titleImage->ai_declaration_source->publicLabel()]) }}</span>
|
||||||
|
@endif
|
||||||
|
@if ($titleImage->width && $titleImage->height)
|
||||||
|
<span>{{ $titleImage->width }}×{{ $titleImage->height }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
{{-- Bestätigungsstand (D.3): Kennung, AGB-Fassung, Zeitpunkt, Kanal. --}}
|
||||||
|
@if ($latestConfirmation)
|
||||||
|
<p class="m-0 text-xs text-zinc-500">
|
||||||
|
{{ __('Rechte bestätigt am :date (:key, AGB :agb, :channel)', [
|
||||||
|
'date' => $latestConfirmation->confirmed_at->format('d.m.Y H:i'),
|
||||||
|
'key' => $latestConfirmation->legal_text_key,
|
||||||
|
'agb' => $latestConfirmation->agb_version ?? '–',
|
||||||
|
'channel' => $latestConfirmation->channel->value,
|
||||||
|
]) }}
|
||||||
|
</p>
|
||||||
|
@elseif ($titleImage->isLegacyRules())
|
||||||
|
<p class="m-0 text-xs text-zinc-400">{{ __('Bestand ohne Bestätigungsprotokoll.') }}</p>
|
||||||
|
@endif
|
||||||
|
</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>
|
||||||
|
|
||||||
|
@if ($awaitsCustomerConfirmation)
|
||||||
|
<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">
|
||||||
|
{{ __('Dieses Bild wurde im Auftrag hochgeladen. Es wird erst veröffentlicht, wenn der Kontoinhaber die Bildrechte bestätigt hat.') }}
|
||||||
|
@if ($isAdminActor)
|
||||||
|
<flux:button size="xs" variant="ghost" class="ml-2" wire:click="resendConfirmationRequest">
|
||||||
|
{{ __('Bestätigungslink erneut senden') }}
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</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, Bildnachweis und KI-Kennzeichnung angeben, bestätigen — fertig.') }}
|
||||||
|
</flux:subheading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ===== 1 · Bild ===== --}}
|
||||||
|
<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') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!$newImage)
|
||||||
|
<flux:file-upload wire:model="newImage" accept="image/jpeg,image/png,image/webp"
|
||||||
|
:description="__('JPG/PNG/WebP, max. :mb MB. Wird als Titelbild gespeichert und ersetzt den Platzhalter.', ['mb' => $maxMb])">
|
||||||
|
<flux:file-upload.dropzone :heading="__('Bild hierher ziehen oder klicken')"
|
||||||
|
:text="__('JPG, PNG oder WebP · max. :mb MB', ['mb' => $maxMb])" 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>
|
||||||
|
@else
|
||||||
|
<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 {{ $noCrop ? 'object-contain' : 'object-cover' }}"
|
||||||
|
style="object-position: {{ str_replace(',', '% ', $focus) }}%" />
|
||||||
|
@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>
|
||||||
|
|
||||||
|
@if ($provenanceState)
|
||||||
|
<div class="rounded-md border p-3 text-xs leading-relaxed
|
||||||
|
{{ $provenanceState['tone'] === 'amber' ? 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100' : ($provenanceState['tone'] === 'green' ? 'border-green-200 bg-green-50 text-green-900 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-100' : 'border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300') }}"
|
||||||
|
data-provenance="{{ $provenanceState['key'] }}">
|
||||||
|
<span class="font-semibold">{{ $provenanceState['title'] }}</span>
|
||||||
|
{{ $provenanceState['text'] }}
|
||||||
|
@if ($provenanceState['key'] !== 'absent')
|
||||||
|
<span class="block mt-1 text-zinc-500">
|
||||||
|
{{ __('Stimmen die Herkunftsdaten nicht? Schreiben Sie uns an :email.', ['email' => config('legal.contact_email')]) }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<flux:select wire:model.live="focus" :label="__('Bildausschnitt: wichtiger Bereich')"
|
||||||
|
:description="__('Der Zuschnitt für Vorschauen hält diesen Bereich im Bild.')">
|
||||||
|
@foreach ($focusOptions as $value => $label)
|
||||||
|
<flux:select.option :value="$value">{{ $label }}</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
<flux:switch wire:model.live="noCrop" align="right" :label="__('Nicht beschneiden')"
|
||||||
|
:description="__('Das ganze Bild bleibt sichtbar, mit neutralem Rand.')" />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<flux:error name="newImage" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if ($newImage)
|
||||||
|
{{-- ===== 2 · Angaben ===== --}}
|
||||||
|
<section class="space-y-4 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">{{ __('Bildnachweis & KI-Kennzeichnung') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:input wire:model="newCopyright" :label="__('Bildnachweis')" :badge="__('Pflicht')"
|
||||||
|
:description="$creditSuggestion !== null
|
||||||
|
? __('Vorschlag aus den Metadaten der Datei — bitte prüfen. So erscheint der Nachweis unter dem Bild („Foto: …").')
|
||||||
|
: __('Vorschlag: Ihr Firmenname — bitte prüfen. Bei Stock- oder Agenturbildern den Nachweis so eintragen, wie die Lizenz ihn verlangt.')"
|
||||||
|
:placeholder="$companyName ?: __('z. B. Max Mustermann / Beispiel GmbH')" required />
|
||||||
|
|
||||||
|
<flux:radio.group wire:model="newAiDeclaration" :label="__('KI-Kennzeichnung')" :badge="__('Pflicht')"
|
||||||
|
:description="__('KI-generierte oder KI-bearbeitete Bilder erhalten öffentlich einen neutralen Hinweis. Übliche Bearbeitungen wie Zuschnitt, Farbkorrektur oder Schärfung gelten nicht als KI-Bearbeitung.')"
|
||||||
|
required>
|
||||||
|
@foreach ($aiOptions as $aiImageOption)
|
||||||
|
<flux:radio :value="$aiImageOption->value" :label="$aiImageOption->label()" />
|
||||||
|
@endforeach
|
||||||
|
</flux:radio.group>
|
||||||
|
<flux:error name="newAiDeclaration" />
|
||||||
|
|
||||||
|
<details class="group" @if ($newLicenseUrl || $newSourceUrl || $newTitle || $newDescription) open @endif>
|
||||||
|
<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">— {{ __('Titel, Bildunterschrift, Lizenz, Quelle') }}</span>
|
||||||
|
</summary>
|
||||||
|
<div class="mt-3 space-y-3">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<flux:input wire:model="newTitle" :label="__('Titel / Alt-Text')"
|
||||||
|
:description="__('Beschreibt das Bild für Suchmaschinen und Screenreader.')" />
|
||||||
|
<flux:input wire:model="newDescription" :label="__('Bildunterschrift')" />
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<flux:input wire:model="newLicenseUrl" type="url" :label="__('Lizenz (Link)')"
|
||||||
|
:description="__('Nur, wenn eine Lizenz die Nutzung regelt, z. B. Creative Commons oder Agentur.')" placeholder="https://…" />
|
||||||
|
<flux:input wire:model="newSourceUrl" type="url" :label="__('Quelle (Link)')"
|
||||||
|
:description="__('Fundstelle oder Ursprungsseite — keine Lizenz.')" placeholder="https://…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- ===== 3 · 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">3</span>
|
||||||
|
<span class="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{{ __('Bestätigung') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($isAdminActor)
|
||||||
|
<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">
|
||||||
|
{{ __('Sie laden im Auftrag des Kunden hoch. Die Rechtebestätigung gibt der Kontoinhaber selbst ab: Er erhält nach dem Upload einen Link „Bild bestätigen". Bis dahin bleibt der Platzhalter aktiv.') }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<flux:switch wire:model="newRightsConfirmed" align="right" :label="__('Ich bestätige')"
|
||||||
|
:description="$legalShort" />
|
||||||
|
<flux:error name="newRightsConfirmed" />
|
||||||
|
|
||||||
|
<button type="button" wire:click="$toggle('showLegalDetails')"
|
||||||
|
class="flex items-center gap-1 text-xs text-zinc-500 underline-offset-2 hover:underline"
|
||||||
|
aria-expanded="{{ $showLegalDetails ? 'true' : 'false' }}">
|
||||||
|
<flux:icon.chevron-right class="size-3.5 transition-transform {{ $showLegalDetails ? 'rotate-90' : '' }}" />
|
||||||
|
{{ __('Vollständiger Wortlaut') }}
|
||||||
|
@if ($legalTextKey)
|
||||||
|
<span class="text-zinc-400">({{ $legalTextKey }})</span>
|
||||||
|
@endif
|
||||||
|
</button>
|
||||||
|
@if ($showLegalDetails && $legalLong)
|
||||||
|
<blockquote class="m-0 rounded-md border border-zinc-200 bg-zinc-50 p-3 text-xs leading-relaxed text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300" data-legal-text>
|
||||||
|
{{ $legalLong }}
|
||||||
|
</blockquote>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</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>
|
||||||
|
|
@ -700,7 +700,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- 7) 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" />
|
@if (config('images.rights_v2'))
|
||||||
|
<livewire:components.press-release-images-manager-v2 :press-release-id="$id" :wire:key="'pr-images-v2-'.$id" />
|
||||||
|
@else
|
||||||
|
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- 7) ANHÄNGE — TEMPORÄR DEAKTIVIERT
|
{{-- 7) ANHÄNGE — TEMPORÄR DEAKTIVIERT
|
||||||
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung
|
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ use App\Services\PressRelease\AiDeclarationRequiredException;
|
||||||
use App\Services\PressRelease\BlacklistViolationException;
|
use App\Services\PressRelease\BlacklistViolationException;
|
||||||
use App\Services\PressRelease\BookingRequiredException;
|
use App\Services\PressRelease\BookingRequiredException;
|
||||||
use App\Services\PressRelease\BoostService;
|
use App\Services\PressRelease\BoostService;
|
||||||
|
use App\Services\Image\ImageRightsTextRenderer;
|
||||||
|
use App\Services\PressRelease\Images\CustomerImageConfirmation;
|
||||||
|
use App\Services\PressRelease\Images\ImageUploadException;
|
||||||
use App\Services\PressRelease\PressReleaseCoverImage;
|
use App\Services\PressRelease\PressReleaseCoverImage;
|
||||||
use App\Services\PressRelease\PressReleaseService;
|
use App\Services\PressRelease\PressReleaseService;
|
||||||
use App\Services\PressRelease\QuotaExceededException;
|
use App\Services\PressRelease\QuotaExceededException;
|
||||||
|
|
@ -105,6 +108,48 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
*/
|
*/
|
||||||
public bool $purchaseConfirmed = false;
|
public bool $purchaseConfirmed = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Bild bestätigen" (Bildkennzeichnung B.4, Decision-Update E10): Schalter
|
||||||
|
* für die Rechtebestätigung eines im Auftrag hochgeladenen Titelbilds.
|
||||||
|
*/
|
||||||
|
public bool $imageRightsConfirmed = false;
|
||||||
|
|
||||||
|
public function confirmImageRights(CustomerImageConfirmation $confirmation, ImageRightsTextRenderer $texts): void
|
||||||
|
{
|
||||||
|
$pr = $this->getMyPR();
|
||||||
|
$this->authorize('update', $pr);
|
||||||
|
|
||||||
|
$image = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first();
|
||||||
|
|
||||||
|
if ($image === null || ! $image->awaitsCustomerConfirmation()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validate(
|
||||||
|
['imageRightsConfirmed' => ['accepted']],
|
||||||
|
['imageRightsConfirmed.accepted' => __('Die Bestätigung ist erforderlich, damit das Bild veröffentlicht werden kann.')],
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$confirmation->confirm(
|
||||||
|
$image,
|
||||||
|
auth()->user(),
|
||||||
|
$texts->current()?->key,
|
||||||
|
request()->ip(),
|
||||||
|
request()->userAgent(),
|
||||||
|
route('agb', ['edition' => 'de']),
|
||||||
|
);
|
||||||
|
} catch (ImageUploadException $exception) {
|
||||||
|
$this->addError('imageRightsConfirmed', $exception->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->imageRightsConfirmed = false;
|
||||||
|
|
||||||
|
Flux::toast(text: __('Bildrechte bestätigt. Das Titelbild kann jetzt veröffentlicht werden.'), variant: 'success');
|
||||||
|
}
|
||||||
|
|
||||||
public function mount(int $id): void
|
public function mount(int $id): void
|
||||||
{
|
{
|
||||||
$this->id = $id;
|
$this->id = $id;
|
||||||
|
|
@ -822,7 +867,14 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
'categoryName' => $categoryName,
|
'categoryName' => $categoryName,
|
||||||
'coverUrl' => $cover->coverUrl($pr, 'cover'),
|
'coverUrl' => $cover->coverUrl($pr, 'cover'),
|
||||||
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
|
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
|
||||||
'titleImage' => $pr->images()->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(),
|
'titleImage' => $titleImage = $pr->images()->with('latestRightsConfirmation')->orderByDesc('is_preview')->orderBy('sort_order')->orderBy('id')->first(),
|
||||||
|
'awaitsImageConfirmation' => $titleImage?->awaitsCustomerConfirmation() === true && auth()->user()->can('update', $pr),
|
||||||
|
'imageLegalShort' => ($imageLegalText = app(ImageRightsTextRenderer::class)->current())
|
||||||
|
? app(ImageRightsTextRenderer::class)->renderShort($imageLegalText, route('agb', ['edition' => 'de']))
|
||||||
|
: null,
|
||||||
|
'imageLegalLong' => $imageLegalText
|
||||||
|
? app(ImageRightsTextRenderer::class)->renderLong($imageLegalText, route('agb', ['edition' => 'de']))
|
||||||
|
: null,
|
||||||
'quotaTotal' => $user->pressReleaseQuotaTotal(),
|
'quotaTotal' => $user->pressReleaseQuotaTotal(),
|
||||||
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
|
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
|
||||||
'canEdit' => auth()->user()->can('update', $pr)
|
'canEdit' => auth()->user()->can('update', $pr)
|
||||||
|
|
@ -1710,6 +1762,40 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
||||||
@endif
|
@endif
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
{{-- ============== BILD BESTÄTIGEN (Admin-Upload, E10) ============== --}}
|
||||||
|
@if ($awaitsImageConfirmation)
|
||||||
|
<article class="panel" data-image-confirmation>
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="section-eyebrow">{{ __('Titelbild bestätigen') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 space-y-4 max-w-[760px]">
|
||||||
|
<p class="m-0 text-[13.5px] leading-relaxed">
|
||||||
|
{{ __('Dieses Titelbild wurde von unserem Team für Sie hinterlegt. Es wird erst veröffentlicht, wenn Sie bestätigen, dass Sie zur Veröffentlichung berechtigt sind und die Angaben stimmen.') }}
|
||||||
|
</p>
|
||||||
|
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-[12.5px] m-0">
|
||||||
|
<dt class="text-[color:var(--color-ink-3)]">{{ __('Bildnachweis') }}</dt>
|
||||||
|
<dd class="m-0">{{ $titleImage->copyright ?: '—' }}</dd>
|
||||||
|
<dt class="text-[color:var(--color-ink-3)]">{{ __('KI-Kennzeichnung') }}</dt>
|
||||||
|
<dd class="m-0">{{ $titleImage->ai_declaration?->label() ?? '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
<flux:switch wire:model="imageRightsConfirmed" align="right" :label="__('Ich bestätige')" :description="$imageLegalShort" />
|
||||||
|
<flux:error name="imageRightsConfirmed" />
|
||||||
|
@if ($imageLegalLong)
|
||||||
|
<details class="group">
|
||||||
|
<summary class="flex cursor-pointer list-none items-center gap-1 text-[12px] text-[color:var(--color-ink-3)] [&::-webkit-details-marker]:hidden">
|
||||||
|
<flux:icon.chevron-right class="size-3.5 transition-transform group-open:rotate-90" />
|
||||||
|
{{ __('Vollständiger Wortlaut') }}
|
||||||
|
</summary>
|
||||||
|
<blockquote class="m-0 mt-2 rounded-md border border-[color:var(--color-bg-rule)] p-3 text-[12px] leading-relaxed text-[color:var(--color-ink-3)]">{{ $imageLegalLong }}</blockquote>
|
||||||
|
</details>
|
||||||
|
@endif
|
||||||
|
<div>
|
||||||
|
<flux:button variant="primary" wire:click="confirmImageRights">{{ __('Bildrechte bestätigen') }}</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- ============== INHALT ============== --}}
|
{{-- ============== INHALT ============== --}}
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
|
|
|
||||||
297
tests/Feature/Images/PressReleaseImageRightsV2Test.php
Normal file
297
tests/Feature/Images/PressReleaseImageRightsV2Test.php
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\AiImageDeclaration;
|
||||||
|
use App\Enums\ConfirmationChannel;
|
||||||
|
use App\Mail\PressReleaseImageConfirmationRequested;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Company;
|
||||||
|
use App\Models\ImageRightsConfirmation;
|
||||||
|
use App\Models\PressRelease;
|
||||||
|
use App\Models\PressReleaseImage;
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\LegalDocumentSeeder;
|
||||||
|
use Database\Seeders\LegalTextSeeder;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Livewire\Volt\Volt as LivewireVolt;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildkennzeichnung Block B: Formular v2 im Titelbild-Manager, Admin-Upload
|
||||||
|
* mit Kundenbestätigung, Entfernen mit Sofortlöschung.
|
||||||
|
*/
|
||||||
|
beforeEach(function (): void {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$this->seed(RolesAndPermissionsSeeder::class);
|
||||||
|
$this->seed(LegalDocumentSeeder::class);
|
||||||
|
$this->seed(LegalTextSeeder::class);
|
||||||
|
Storage::fake('public');
|
||||||
|
Storage::fake('local');
|
||||||
|
config()->set('images.rights_v2', true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{owner: User, pr: PressRelease, company: Company}
|
||||||
|
*/
|
||||||
|
function makeRightsV2Draft(): array
|
||||||
|
{
|
||||||
|
$owner = User::factory()->create(['is_active' => true]);
|
||||||
|
$owner->assignRole('customer');
|
||||||
|
|
||||||
|
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG']);
|
||||||
|
$owner->companies()->attach($company->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
$pr = PressRelease::factory()->create([
|
||||||
|
'user_id' => $owner->id,
|
||||||
|
'company_id' => $company->id,
|
||||||
|
'category_id' => Category::factory()->create()->id,
|
||||||
|
'portal' => $company->portal->value,
|
||||||
|
'status' => 'draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return compact('owner', 'pr', 'company');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rightsV2Fixture(string $name): UploadedFile
|
||||||
|
{
|
||||||
|
return UploadedFile::fake()->createWithContent($name, (string) file_get_contents(base_path('tests/Fixtures/images/'.$name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the edit page embeds the v2 manager only while the flag is on', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||||
|
->assertSeeLivewire('components.press-release-images-manager-v2');
|
||||||
|
|
||||||
|
config()->set('images.rights_v2', false);
|
||||||
|
|
||||||
|
LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id])
|
||||||
|
->assertSeeLivewire('components.press-release-images-manager')
|
||||||
|
->assertDontSeeLivewire('components.press-release-images-manager-v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nachweis, kennzeichnung and the confirmation are the only required inputs', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
|
||||||
|
->call('openUploadForm')
|
||||||
|
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||||
|
->set('newCopyright', '')
|
||||||
|
->call('saveImage')
|
||||||
|
->assertHasErrors(['newCopyright', 'newAiDeclaration', 'newRightsConfirmed'])
|
||||||
|
->assertHasNoErrors(['newTitle', 'newLicenseUrl', 'newSourceUrl']);
|
||||||
|
|
||||||
|
expect($pr->images()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without metadata the company name is suggested as credit and the state says so', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
|
||||||
|
->call('openUploadForm')
|
||||||
|
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||||
|
->assertSet('newCopyright', 'Beispiel AG')
|
||||||
|
->assertSet('provenanceStatus', 'absent')
|
||||||
|
->assertSee('Keine Herkunftsdaten in der Datei gefunden.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('iptc and xmp metadata prefill credit and ai declaration and can be changed', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$component = LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
|
||||||
|
->call('openUploadForm')
|
||||||
|
->set('newImage', rightsV2Fixture('camera-iptc.jpg'))
|
||||||
|
->assertSet('newCopyright', 'Musterfirma GmbH / Anna Beispiel')
|
||||||
|
->assertSet('newAiDeclaration', '')
|
||||||
|
->assertSee('Angaben aus den Metadaten der Datei vorbelegt.');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->set('newImage', rightsV2Fixture('xmp-ai-generated.jpg'))
|
||||||
|
->assertSet('newCopyright', 'KI-Bild / Musterfirma')
|
||||||
|
->assertSet('newAiDeclaration', 'ai_generated')
|
||||||
|
->assertSee('Sie können die Auswahl ändern')
|
||||||
|
->set('newAiDeclaration', 'original');
|
||||||
|
|
||||||
|
expect($component->get('newAiDeclaration'))->toBe('original');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a manual credit survives a file change but the confirmation toggle does not', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
|
||||||
|
->call('openUploadForm')
|
||||||
|
->set('newImage', UploadedFile::fake()->image('a.jpg', 640, 480))
|
||||||
|
->set('newCopyright', 'Eigener Nachweis')
|
||||||
|
->set('newRightsConfirmed', true)
|
||||||
|
->set('newImage', rightsV2Fixture('camera-iptc.jpg'))
|
||||||
|
->assertSet('newCopyright', 'Eigener Nachweis')
|
||||||
|
->assertSet('newRightsConfirmed', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unreadable file shows the error state and is not accepted', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
|
||||||
|
->call('openUploadForm')
|
||||||
|
->set('newImage', rightsV2Fixture('broken.jpg'))
|
||||||
|
->assertHasErrors(['newImage']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a valid upload stores the image under the new rules with a confirmation and shows it on the card', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
|
||||||
|
->call('openUploadForm')
|
||||||
|
->set('newImage', rightsV2Fixture('camera-iptc.jpg'))
|
||||||
|
->set('newAiDeclaration', AiImageDeclaration::Original->value)
|
||||||
|
->set('newTitle', 'Werkshalle')
|
||||||
|
->set('newLicenseUrl', 'https://example.com/lizenz')
|
||||||
|
->set('focus', '100,0')
|
||||||
|
->set('newRightsConfirmed', true)
|
||||||
|
->call('saveImage')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSet('isUploadFormOpen', false)
|
||||||
|
->assertDispatched('title-image-changed')
|
||||||
|
->assertSee('Rechte bestätigt am')
|
||||||
|
->assertSee('img-confirm-v2');
|
||||||
|
|
||||||
|
$image = $pr->images()->sole();
|
||||||
|
|
||||||
|
expect($image->rules_version)->toBe(config('images.rules_version'))
|
||||||
|
->and($image->copyright)->toBe('Musterfirma GmbH / Anna Beispiel')
|
||||||
|
->and($image->credit_source?->value)->toBe('metadata')
|
||||||
|
->and($image->title)->toBe('Werkshalle')
|
||||||
|
->and($image->license_url)->toBe('https://example.com/lizenz')
|
||||||
|
->and($image->focus_x)->toBe(100)
|
||||||
|
->and($image->focus_y)->toBe(0)
|
||||||
|
->and($image->license_type)->toBeNull()
|
||||||
|
->and($image->people_rights_status)->toBeNull()
|
||||||
|
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
|
||||||
|
->and($image->hasValidRightsConfirmation())->toBeTrue();
|
||||||
|
|
||||||
|
$confirmation = ImageRightsConfirmation::query()->sole();
|
||||||
|
|
||||||
|
expect($confirmation->channel)->toBe(ConfirmationChannel::Form)
|
||||||
|
->and($confirmation->user_id)->toBe($owner->id)
|
||||||
|
->and($confirmation->legal_text_key)->toBe('img-confirm-v2')
|
||||||
|
->and($confirmation->text_snapshot)->toContain('/de/agb');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an admin upload skips the confirmation, mails the customer and blocks nothing else', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
Mail::fake();
|
||||||
|
['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft();
|
||||||
|
$admin = User::factory()->create(['is_active' => true]);
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
|
||||||
|
->call('openUploadForm')
|
||||||
|
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||||
|
->set('newCopyright', 'Beispiel AG')
|
||||||
|
->set('newAiDeclaration', AiImageDeclaration::Original->value)
|
||||||
|
->assertSee('Sie laden im Auftrag des Kunden hoch')
|
||||||
|
->call('saveImage')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSee('wartet auf Kundenbestätigung');
|
||||||
|
|
||||||
|
$image = $pr->images()->sole();
|
||||||
|
|
||||||
|
expect($image->customer_confirmation_requested_at)->not->toBeNull()
|
||||||
|
->and($image->awaitsCustomerConfirmation())->toBeTrue()
|
||||||
|
->and(ImageRightsConfirmation::query()->count())->toBe(0);
|
||||||
|
|
||||||
|
Mail::assertQueued(PressReleaseImageConfirmationRequested::class, fn ($mail) => $mail->hasTo($owner->email) && $mail->image->is($image));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the customer confirms an admin upload on the detail page with the magic link channel', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft();
|
||||||
|
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([
|
||||||
|
'rights_confirmed_at' => null,
|
||||||
|
'customer_confirmation_requested_at' => now(),
|
||||||
|
]);
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||||
|
->assertSee('Titelbild bestätigen')
|
||||||
|
->call('confirmImageRights')
|
||||||
|
->assertHasErrors(['imageRightsConfirmed'])
|
||||||
|
->set('imageRightsConfirmed', true)
|
||||||
|
->call('confirmImageRights')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertDontSee('Titelbild bestätigen');
|
||||||
|
|
||||||
|
$confirmation = $image->rightsConfirmations()->sole();
|
||||||
|
|
||||||
|
expect($confirmation->channel)->toBe(ConfirmationChannel::MagicLink)
|
||||||
|
->and($confirmation->user_id)->toBe($owner->id)
|
||||||
|
->and($confirmation->file_sha256)->toBe($image->original_sha256)
|
||||||
|
->and($image->fresh()->awaitsCustomerConfirmation())->toBeFalse()
|
||||||
|
->and($image->fresh()->rights_confirmed_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing a never published image deletes original, derivatives and confirmation for good', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
$component = LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
|
||||||
|
->call('openUploadForm')
|
||||||
|
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||||
|
->set('newAiDeclaration', AiImageDeclaration::Original->value)
|
||||||
|
->set('newRightsConfirmed', true)
|
||||||
|
->call('saveImage')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$image = $pr->images()->sole();
|
||||||
|
expect(Storage::disk('local')->allFiles())->not->toBe([]);
|
||||||
|
|
||||||
|
$component->call('remove', $image->id)->assertDispatched('title-image-changed');
|
||||||
|
|
||||||
|
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse()
|
||||||
|
->and(ImageRightsConfirmation::query()->count())->toBe(0)
|
||||||
|
->and(Storage::disk('local')->allFiles())->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing a once published image keeps the original and the confirmation and only soft deletes', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft();
|
||||||
|
$admin = User::factory()->create(['is_active' => true]);
|
||||||
|
$admin->assignRole('admin');
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
|
||||||
|
->call('openUploadForm')
|
||||||
|
->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800))
|
||||||
|
->set('newCopyright', 'Beispiel AG')
|
||||||
|
->set('newAiDeclaration', AiImageDeclaration::Original->value)
|
||||||
|
->call('saveImage')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$image = $pr->images()->sole();
|
||||||
|
$image->forceFill(['published_ever_at' => now()])->save();
|
||||||
|
|
||||||
|
LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id])
|
||||||
|
->call('remove', $image->id);
|
||||||
|
|
||||||
|
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
|
||||||
|
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
|
||||||
|
->and(Storage::disk('local')->exists($image->path))->toBeFalse();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue