User Panel: Phase-8-Abschluss, Titelbild/Lizenzen/Zeitzonen und KI-Pruef-Pipeline
Phase 8 (Rest) + Umbauten vom 10./11.06.: - Ein Titelbild pro PM (Cover 1280x580), SVG-Platzhalter-Set + Picker, PressReleaseCoverImage-Resolver - Lizenz-/Rechteformular nach "Lizenztyp Bildupload" (7 Lizenztypen, Personen-/Sachrechte-Status, bedingte Pflichtfelder, Risikohinweise) - Veroeffentlichungs-Box vereinfacht (Embargo aus der Form-UI entfernt), geplante Termine in Europe/Berlin (Speicherung UTC, DISPLAY_TIMEZONE) - Quota-Stub (users.press_release_quota) + monatlicher Reset-Command - Einreichungs-Modal einheitlich in Show/Create/Edit; Ghost-Buttons auf filled; PM-Editor-Layout responsive entkoppelt (.pr-editor-layout) KI-Pruef-Pipeline (Phasen 1-5 des Entwicklungsplans): - API-Haertung: status nicht mehr per API setzbar, eigene Submit-Route durch denselben Funnel (Blacklist, Quota, Status-Log) - Klassifikation Rot/Gelb/Gruen asynchron (Queue classification, OpenAI-Treiber + deterministischer Fallback), ki_audits-Audit-Log - Routing: Rot -> rejected + Mail, Gelb -> Review-Queue, Gruen -> Auto-Publish; Scheduler publiziert nur gruene faellige PMs - Content-Score 0-100 -> Stufe (Standard/Geprueft/Hochwertig) inkl. Editor-Panel und Badges; Re-Klassifikation/-Score bei Aenderung - Admin: KI-Badge + Filter, On-Demand-Pruefung mit Anbieter-Override Suite: 442 passed, 4 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0efabaf446
commit
a000238ca8
141 changed files with 5922 additions and 1001 deletions
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleasePlaceholder;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
|
|
@ -14,6 +15,7 @@ use Illuminate\Support\Str;
|
|||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
|
|
@ -25,6 +27,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
|
||||
public int|string|null $companyId = null;
|
||||
|
||||
public string $companySearch = '';
|
||||
|
||||
public int|string|null $categoryId = null;
|
||||
|
||||
public int|string|null $contactId = null;
|
||||
|
|
@ -47,21 +51,35 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
|
||||
public ?string $scheduledAt = null;
|
||||
|
||||
public ?string $scheduledDate = null;
|
||||
|
||||
public ?string $scheduledTime = null;
|
||||
|
||||
public bool $useEmbargo = false;
|
||||
|
||||
public ?string $embargoAt = null;
|
||||
|
||||
public string $placeholderVariant = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$firstCompany = $context->selectedCompany($user) ?? $context->companiesFor($user)->first();
|
||||
$firstCompany = $context->selectedCompany($user) ?? $context->latestCompaniesFor($user, 1)->first();
|
||||
|
||||
if ($firstCompany) {
|
||||
$this->companyId = $firstCompany->id;
|
||||
$this->portal = $firstCompany->portal?->value ?? Portal::Presseecho->value;
|
||||
$this->contactId = $this->defaultContactIdFor((int) $firstCompany->id);
|
||||
}
|
||||
|
||||
$this->placeholderVariant = PressReleasePlaceholder::default()->value;
|
||||
}
|
||||
|
||||
#[On('placeholder-selected')]
|
||||
public function setPlaceholderVariant(string $variant): void
|
||||
{
|
||||
$this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($variant)->value;
|
||||
}
|
||||
|
||||
public function updatedCompanyId(): void
|
||||
|
|
@ -79,11 +97,44 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
unset($this->tags, $this->presubmitChecks);
|
||||
}
|
||||
|
||||
public function updatedCompanySearch(): void
|
||||
{
|
||||
$this->resetErrorBag('companyId');
|
||||
}
|
||||
|
||||
public function updatedPublishMode(): void
|
||||
{
|
||||
$this->syncScheduledAt();
|
||||
|
||||
if ($this->publishMode === 'now') {
|
||||
$this->scheduledDate = null;
|
||||
$this->scheduledTime = null;
|
||||
$this->scheduledAt = null;
|
||||
$this->resetErrorBag(['scheduledDate', 'scheduledTime', 'scheduledAt']);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedScheduledDate(): void
|
||||
{
|
||||
$this->syncScheduledAt();
|
||||
$this->validateScheduledAtWhenReady();
|
||||
}
|
||||
|
||||
public function updatedScheduledTime(): void
|
||||
{
|
||||
$this->syncScheduledAt();
|
||||
$this->validateScheduledAtWhenReady();
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-Re-Validation: sobald für ein Property bereits ein Error im Bag
|
||||
* liegt, wird es bei jeder Änderung neu geprüft. So verschwindet ein
|
||||
* roter Hinweis sofort, wenn der User das Feld korrekt ausfüllt — und
|
||||
* der User muss nicht erst auf „Entwurf speichern" klicken.
|
||||
*
|
||||
* Die Termin-Synchronisierung liegt vollständig in den spezifischen
|
||||
* `updated{PublishMode,ScheduledDate,ScheduledTime}`-Hooks; hier bleibt
|
||||
* nur die generische Re-Validierung bereits fehlerhafter Felder.
|
||||
*/
|
||||
public function updated(string $property): void
|
||||
{
|
||||
|
|
@ -145,20 +196,93 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
// Min. 5 Minuten in der Zukunft, damit der Background-Job (alle 5 Min)
|
||||
// die PM verlässlich rechtzeitig fängt.
|
||||
if ($this->publishMode === 'scheduled') {
|
||||
$rules['scheduledAt'] = ['required', 'date', 'after:'.now()->addMinutes(5)->toIso8601String()];
|
||||
$rules['scheduledDate'] = ['required', 'date'];
|
||||
$rules['scheduledTime'] = ['required', 'date_format:H:i'];
|
||||
$rules['scheduledAt'] = [
|
||||
'required',
|
||||
'date',
|
||||
// Termin wird in Europe/Berlin erfasst; deshalb hier zeitzonen-
|
||||
// bewusst prüfen statt über die naive `after:`-Regel.
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
$scheduledAt = $this->scheduledAtUtc();
|
||||
|
||||
if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) {
|
||||
$fail(__('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.'));
|
||||
}
|
||||
},
|
||||
];
|
||||
} else {
|
||||
$rules['scheduledDate'] = ['nullable'];
|
||||
$rules['scheduledTime'] = ['nullable'];
|
||||
$rules['scheduledAt'] = ['nullable'];
|
||||
}
|
||||
|
||||
if ($this->useEmbargo) {
|
||||
$rules['embargoAt'] = ['required', 'date', 'after:'.now()->toIso8601String()];
|
||||
} else {
|
||||
$rules['embargoAt'] = ['nullable'];
|
||||
}
|
||||
$rules['embargoAt'] = ['nullable'];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
protected function syncScheduledAt(): void
|
||||
{
|
||||
if ($this->publishMode !== 'scheduled') {
|
||||
$this->scheduledAt = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (blank($this->scheduledDate) && blank($this->scheduledTime) && filled($this->scheduledAt)) {
|
||||
$scheduledAt = \Carbon\Carbon::parse($this->scheduledAt);
|
||||
$this->scheduledDate = $scheduledAt->format('Y-m-d');
|
||||
$this->scheduledTime = $scheduledAt->format('H:i');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (blank($this->scheduledDate) || blank($this->scheduledTime)) {
|
||||
$this->scheduledAt = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->scheduledAt = "{$this->scheduledDate}T{$this->scheduledTime}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt den in Europe/Berlin erfassten Termin in den UTC-Zeitpunkt,
|
||||
* wie er in der Datenbank gespeichert wird. Null, wenn kein Termin gesetzt.
|
||||
*/
|
||||
protected function scheduledAtUtc(): ?\Carbon\Carbon
|
||||
{
|
||||
if (blank($this->scheduledAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return \Carbon\Carbon::parse($this->scheduledAt, PressRelease::DISPLAY_TIMEZONE)->utc();
|
||||
}
|
||||
|
||||
protected function validateScheduledAtWhenReady(): void
|
||||
{
|
||||
if (blank($this->scheduledAt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resetErrorBag('scheduledAt');
|
||||
|
||||
$scheduledAt = $this->scheduledAtUtc();
|
||||
|
||||
if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) {
|
||||
$this->addError('scheduledAt', __('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validateOnly('scheduledAt', $this->formRules());
|
||||
} catch (\Illuminate\Validation\ValidationException) {
|
||||
// Termin bleibt invalid — Error-Bag wird automatisch befüllt.
|
||||
}
|
||||
}
|
||||
|
||||
public function addTag(string $tag): void
|
||||
{
|
||||
$tag = trim($tag);
|
||||
|
|
@ -195,8 +319,77 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
unset($this->tags, $this->presubmitChecks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy Auto-Draft: legt sofort einen Entwurf an, damit Titelbild und
|
||||
* Einstellungen schon vor dem finalen Speichern gepflegt werden
|
||||
* können. Erfordert nur Firma und Kategorie (category_id ist NOT NULL),
|
||||
* alle übrigen Felder werden – soweit erfasst – übernommen. Anschließend
|
||||
* wird in den vollwertigen Editor (Edit-Seite) weitergeleitet, wo der
|
||||
* Bild-Manager direkt zur Verfügung steht.
|
||||
*/
|
||||
public function ensureDraft(): void
|
||||
{
|
||||
try {
|
||||
$this->validate([
|
||||
'companyId' => ['required', 'integer'],
|
||||
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
|
||||
], [
|
||||
'companyId.required' => __('Bitte zuerst eine Firma wählen, bevor du ein Titelbild hochlädst.'),
|
||||
'categoryId.required' => __('Bitte zuerst eine Kategorie wählen, bevor du ein Titelbild hochlädst.'),
|
||||
]);
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
$this->notifyValidationError($e);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$company = $this->selectedCompany();
|
||||
|
||||
if (! $company) {
|
||||
$this->addError('companyId', __('Die gewählte Firma ist nicht Ihrem Account zugeordnet.'));
|
||||
$this->notifyValidationError();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->portal = $company->portal?->value ?? Portal::Presseecho->value;
|
||||
|
||||
$slug = (new PressRelease)->generateUniqueSlug($this->title ?: __('Entwurf'), [
|
||||
'portal' => $this->portal,
|
||||
'language' => $this->language,
|
||||
]);
|
||||
|
||||
$pr = PressRelease::query()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'status' => PressReleaseStatus::Draft->value,
|
||||
...$this->pressReleaseAttributes($slug),
|
||||
]);
|
||||
|
||||
if ($this->contactId) {
|
||||
$contact = $this->companyContact((int) $this->contactId, (int) $company->id);
|
||||
|
||||
if ($contact) {
|
||||
$pr->contacts()->sync([$contact->id]);
|
||||
}
|
||||
}
|
||||
|
||||
Flux::toast(
|
||||
heading: __('Entwurf gesichert'),
|
||||
text: __('Du kannst jetzt ein Titelbild hochladen und alle Einstellungen vornehmen. Der Entwurf liegt unter „Meine PMs".'),
|
||||
variant: 'success',
|
||||
);
|
||||
|
||||
$this->redirect(route('me.press-releases.edit', $pr->id), navigate: true);
|
||||
}
|
||||
|
||||
public function save(string $submitStatus = 'draft'): void
|
||||
{
|
||||
$this->syncScheduledAt();
|
||||
$this->useEmbargo = false;
|
||||
$this->embargoAt = null;
|
||||
|
||||
try {
|
||||
$this->validate($this->formRules());
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
|
|
@ -237,31 +430,11 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
'language' => $this->language,
|
||||
]);
|
||||
|
||||
$cleanText = app(PressReleaseHtmlSanitizer::class)->clean($this->text);
|
||||
|
||||
$pr = PressRelease::query()->create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'portal' => $this->portal,
|
||||
'language' => $this->language,
|
||||
'user_id' => $user->id,
|
||||
'company_id' => (int) $this->companyId,
|
||||
'category_id' => (int) $this->categoryId,
|
||||
'title' => $this->title,
|
||||
'subtitle' => trim($this->subtitle) ?: null,
|
||||
'slug' => $slug,
|
||||
'text' => $cleanText,
|
||||
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== ''
|
||||
? trim($this->boilerplateOverride)
|
||||
: null,
|
||||
'keywords' => $this->keywords ?: null,
|
||||
'backlink_url' => $this->backlinkUrl ?: null,
|
||||
'scheduled_at' => $this->publishMode === 'scheduled' && $this->scheduledAt
|
||||
? \Carbon\Carbon::parse($this->scheduledAt)
|
||||
: null,
|
||||
'embargo_at' => $this->useEmbargo && $this->embargoAt
|
||||
? \Carbon\Carbon::parse($this->embargoAt)
|
||||
: null,
|
||||
'status' => $status->value,
|
||||
...$this->pressReleaseAttributes($slug),
|
||||
]);
|
||||
|
||||
if ($contact) {
|
||||
|
|
@ -281,12 +454,46 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
$this->redirect(route('me.press-releases.show', $pr->id), navigate: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsame Spaltenwerte für Create- und Auto-Draft-Anlage.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function pressReleaseAttributes(string $slug): array
|
||||
{
|
||||
return [
|
||||
'portal' => $this->portal,
|
||||
'language' => $this->language,
|
||||
'company_id' => (int) $this->companyId,
|
||||
'category_id' => (int) $this->categoryId,
|
||||
'title' => trim($this->title) !== '' ? $this->title : __('Unbenannter Entwurf'),
|
||||
'subtitle' => trim($this->subtitle) ?: null,
|
||||
'slug' => $slug,
|
||||
'text' => app(PressReleaseHtmlSanitizer::class)->clean($this->text),
|
||||
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== ''
|
||||
? trim($this->boilerplateOverride)
|
||||
: null,
|
||||
'placeholder_variant' => $this->placeholderVariant ?: PressReleasePlaceholder::default()->value,
|
||||
'keywords' => $this->keywords ?: null,
|
||||
'backlink_url' => $this->backlinkUrl ?: null,
|
||||
'scheduled_at' => $this->publishMode === 'scheduled'
|
||||
? $this->scheduledAtUtc()
|
||||
: null,
|
||||
'embargo_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$myCompanies = $context->companiesFor($user);
|
||||
$selectedCompany = $this->selectedCompany();
|
||||
$companyOptions = $context->searchCompaniesFor(
|
||||
$user,
|
||||
$this->companySearch,
|
||||
$this->companyId ? (int) $this->companyId : null,
|
||||
10,
|
||||
);
|
||||
|
||||
$categories = Category::query()
|
||||
->with('translations')
|
||||
|
|
@ -295,7 +502,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
->get();
|
||||
|
||||
return [
|
||||
'myCompanies' => $myCompanies,
|
||||
'companyOptions' => $companyOptions,
|
||||
'categories' => $categories,
|
||||
'selectedCompany' => $selectedCompany,
|
||||
'selectedCompanyContacts' => $selectedCompany
|
||||
|
|
@ -303,6 +510,8 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
: Contact::query()->whereRaw('0 = 1')->get(),
|
||||
'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'),
|
||||
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
|
||||
'quotaTotal' => (int) $user->press_release_quota,
|
||||
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -442,7 +651,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8" x-data="{ tagInput: '' }">
|
||||
<div class="space-y-8 pr-editor-shell" x-data="{ tagInput: '' }">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
<div class="min-w-0">
|
||||
|
|
@ -460,38 +669,64 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||||
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||||
{{ __('Zur Liste') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{-- ============== 2-COLUMN GRID ============== --}}
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div class="grid gap-6 pr-editor-layout">
|
||||
|
||||
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
|
||||
<div class="space-y-6 min-w-0">
|
||||
|
||||
{{-- 1) FIRMA-SELEKTOR --}}
|
||||
<section class="panel">
|
||||
<div class="p-4 flex flex-wrap items-center gap-4">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">{{ __('Für Firma') }}</span>
|
||||
<flux:select wire:model.live="companyId" class="!w-auto min-w-[260px]">
|
||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||
@foreach ($myCompanies as $c)
|
||||
<option value="{{ $c->id }}">{{ $c->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Boilerplate und Pressekontakt werden vorbefüllt.') }}
|
||||
</span>
|
||||
<span class="flex-1"></span>
|
||||
@if ($selectedCompany)
|
||||
<flux:button size="sm" variant="ghost" icon="building-office"
|
||||
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
|
||||
{{ __('Firmenprofil') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
<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>
|
||||
<flux:select
|
||||
wire:model.live="companyId"
|
||||
variant="combobox"
|
||||
:filter="false"
|
||||
clearable
|
||||
placeholder="{{ __('Firma suchen…') }}"
|
||||
class="w-full sm:flex-1"
|
||||
>
|
||||
<x-slot name="input">
|
||||
<flux:select.input
|
||||
wire:model.live.debounce.300ms="companySearch"
|
||||
placeholder="{{ __('Name eingeben…') }}"
|
||||
/>
|
||||
</x-slot>
|
||||
@foreach ($companyOptions as $company)
|
||||
<flux:select.option :value="$company->id" wire:key="company-option-{{ $company->id }}">
|
||||
{{ $company->name }}{{ $company->portal ? ' ('.$company->portal->abbreviation().')' : '' }}
|
||||
</flux:select.option>
|
||||
@endforeach
|
||||
<x-slot name="empty">
|
||||
<flux:select.option.empty>
|
||||
@if (blank(trim($companySearch)))
|
||||
{{ __('Keine Firma verfügbar.') }}
|
||||
@else
|
||||
{{ __('Keine Firma gefunden.') }}
|
||||
@endif
|
||||
</flux:select.option.empty>
|
||||
</x-slot>
|
||||
</flux:select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Boilerplate und Pressekontakt werden vorbefüllt.') }}
|
||||
</span>
|
||||
@if ($selectedCompany)
|
||||
<flux:button size="sm" variant="filled" icon="building-office"
|
||||
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
|
||||
{{ __('Firmenprofil') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<flux:error name="companyId" />
|
||||
</section>
|
||||
|
|
@ -594,27 +829,62 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{{-- 5) MEDIEN (nach Speichern verfügbar) --}}
|
||||
{{-- 5) TITELBILD --}}
|
||||
<section class="panel">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between mb-3 gap-4">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">
|
||||
{{ __('Medien / Bilder') }}
|
||||
<span class="text-[color:var(--color-ink-4)] font-normal" style="letter-spacing:0;text-transform:none;">
|
||||
— {{ __('nach Speichern verfügbar') }}
|
||||
</span>
|
||||
{{ __('Titelbild') }}
|
||||
</span>
|
||||
<span class="pr-bald-badge">{{ __('KI-Bildgenerierung · bald') }}</span>
|
||||
</div>
|
||||
|
||||
{{-- Titelbild-Platzhalter (bis ein eigenes Bild hochgeladen ist) --}}
|
||||
<div class="mb-4">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<span class="text-[12px] font-semibold text-[color:var(--color-ink-2)]">{{ __('Titelbild-Platzhalter') }}</span>
|
||||
<flux:modal.trigger name="placeholder-picker">
|
||||
<flux:button size="xs" variant="filled" icon="swatch">
|
||||
{{ __('Platzhalter wählen') }}
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
{{-- Anzeige analog Detailansicht: max. 1280×580, zentriert begrenzt --}}
|
||||
<x-portal.press-release-placeholder
|
||||
:variant="$placeholderVariant"
|
||||
:title="$title ?: null"
|
||||
class="mx-auto aspect-[1280/580] w-full max-w-[1280px] rounded-[6px] border border-[color:var(--color-bg-rule)]" />
|
||||
<p class="mt-2 text-[11.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Dieser Platzhalter wird angezeigt, bis ein eigenes Titelbild hochgeladen ist.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[5px] border border-dashed border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-5 text-center">
|
||||
<flux:icon name="photo" class="mx-auto mb-2 size-8 text-[color:var(--color-ink-4)]" />
|
||||
<p class="text-[12.5px] text-[color:var(--color-ink-2)] m-0">
|
||||
{{ __('Sobald die Pressemitteilung als Entwurf gespeichert ist, kannst du Bilder hinzufügen, ein Titelbild festlegen und Bildunterschriften/Alt-Texte pflegen.') }}
|
||||
{{ __('Lade ein eigenes Titelbild hoch. Dafür wird automatisch ein Entwurf gesichert — danach kannst du alles weiter bearbeiten.') }}
|
||||
</p>
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="primary"
|
||||
icon="arrow-up-tray"
|
||||
class="mt-3"
|
||||
wire:click="ensureDraft"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="ensureDraft"
|
||||
>
|
||||
{{ __('Titelbild hochladen & Entwurf sichern') }}
|
||||
</flux:button>
|
||||
<p class="mt-2 text-[11px] text-[color:var(--color-ink-4)] m-0">
|
||||
{{ __('Erfordert nur Firma + Kategorie. Du landest danach im Editor mit Bild-Upload.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Titelbild-Platzhalter-Auswahl --}}
|
||||
<livewire:components.press-release-placeholder-picker :current="$placeholderVariant" />
|
||||
|
||||
{{-- 6) ANHÄNGE — TEMPORÄR DEAKTIVIERT
|
||||
Datei-Uploads erfordern eine vollständige Sicherheitsprüfung
|
||||
(Virus-/Malware-Scan, MIME-Validierung, Storage-Quoten).
|
||||
|
|
@ -689,7 +959,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
{{-- /Schreibfläche --}}
|
||||
|
||||
{{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}}
|
||||
<aside class="space-y-4 lg:sticky lg:top-4 self-start">
|
||||
<aside class="space-y-4 pr-editor-side self-start">
|
||||
|
||||
{{-- Status & Absenden --}}
|
||||
<article class="panel" style="border-color:var(--color-hub);">
|
||||
|
|
@ -733,16 +1003,17 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
@endforeach
|
||||
</div>
|
||||
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="primary"
|
||||
icon="paper-airplane"
|
||||
class="w-full"
|
||||
wire:click="save('review')"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
{{ __('Zur Prüfung senden') }}
|
||||
</flux:button>
|
||||
<flux:modal.trigger name="confirm-submit-review">
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="primary"
|
||||
icon="paper-airplane"
|
||||
class="w-full"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
{{ __('Zur Prüfung senden') }}
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
|
||||
{{ __('Warnungen blockieren nicht. Pflichtfelder blockieren. Die Redaktion prüft typ. innerhalb von 24h.') }}
|
||||
</p>
|
||||
|
|
@ -750,7 +1021,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
<hr class="border-0 border-t border-[color:var(--color-bg-rule)] my-3" />
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="filled"
|
||||
icon="bookmark"
|
||||
class="w-full"
|
||||
wire:click="save('draft')"
|
||||
|
|
@ -822,7 +1093,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
{{ __('Diese Firma hat noch keine Pressekontakte.') }}
|
||||
</p>
|
||||
@if ($selectedCompany)
|
||||
<flux:button size="sm" variant="ghost" icon="plus" class="w-full"
|
||||
<flux:button size="sm" variant="filled" icon="plus" class="w-full"
|
||||
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
|
||||
{{ __('Kontakt im Firmenprofil anlegen') }}
|
||||
</flux:button>
|
||||
|
|
@ -952,39 +1223,31 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</label>
|
||||
|
||||
@if ($publishMode === 'scheduled')
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Veröffentlichungstermin') }}</flux:label>
|
||||
<flux:input
|
||||
wire:model.live="scheduledAt"
|
||||
type="datetime-local"
|
||||
:min="now()->addMinutes(5)->format('Y-m-d\\TH:i')"
|
||||
/>
|
||||
<flux:description>{{ __('Frühestens 5 Min. in der Zukunft.') }}</flux:description>
|
||||
<flux:error name="scheduledAt" />
|
||||
</flux:field>
|
||||
@endif
|
||||
|
||||
<div class="border-t pt-3" style="border-color: var(--color-line);">
|
||||
<flux:switch
|
||||
wire:model.live="useEmbargo"
|
||||
:label="__('Sperrfrist (Embargo) setzen')"
|
||||
/>
|
||||
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-1 leading-tight">
|
||||
{{ __('Die PM ist intern frei, aber öffentlich erst ab gewähltem Zeitpunkt sichtbar.') }}
|
||||
</p>
|
||||
|
||||
@if ($useEmbargo)
|
||||
<flux:field class="mt-3">
|
||||
<flux:label>{{ __('Sperrfrist bis') }}</flux:label>
|
||||
<flux:input
|
||||
wire:model.live="embargoAt"
|
||||
type="datetime-local"
|
||||
:min="now()->format('Y-m-d\\TH:i')"
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Datum') }}</flux:label>
|
||||
<flux:date-picker
|
||||
wire:model.live="scheduledDate"
|
||||
type="input"
|
||||
:placeholder="__('Datum wählen')"
|
||||
with-today
|
||||
/>
|
||||
<flux:error name="embargoAt" />
|
||||
<flux:error name="scheduledDate" />
|
||||
</flux:field>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Uhrzeit') }}</flux:label>
|
||||
<flux:time-picker
|
||||
wire:model.live="scheduledTime"
|
||||
type="input"
|
||||
:placeholder="__('Uhrzeit wählen')"
|
||||
/>
|
||||
<flux:error name="scheduledTime" />
|
||||
</flux:field>
|
||||
</div>
|
||||
<flux:error name="scheduledAt" />
|
||||
<p class="text-[11px] text-[color:var(--color-ink-3)] leading-tight">{{ __('Frühestens 5 Min. in der Zukunft.') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
|
@ -1027,4 +1290,12 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
|
|||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Einreichungs-Modal (öffnet über „Zur Prüfung senden") --}}
|
||||
<x-press-release-submit-modal
|
||||
name="confirm-submit-review"
|
||||
action="save('review')"
|
||||
:confirm-label="__('Zur Prüfung senden')"
|
||||
:quota-total="$quotaTotal"
|
||||
:quota-remaining="$quotaRemaining" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleasePlaceholder;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
|
|
@ -8,6 +9,7 @@ use App\Models\Contact;
|
|||
use App\Models\PressRelease;
|
||||
use App\Services\Customer\CustomerCompanyContext;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseCoverImage;
|
||||
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Flux\Flux;
|
||||
|
|
@ -16,6 +18,7 @@ use Illuminate\Validation\Rule;
|
|||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
|
|
@ -30,6 +33,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
|
||||
public int|string|null $companyId = null;
|
||||
|
||||
public string $companySearch = '';
|
||||
|
||||
public int|string|null $categoryId = null;
|
||||
|
||||
public int|string|null $contactId = null;
|
||||
|
|
@ -52,12 +57,24 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
|
||||
public ?string $scheduledAt = null;
|
||||
|
||||
public ?string $scheduledDate = null;
|
||||
|
||||
public ?string $scheduledTime = null;
|
||||
|
||||
public bool $useEmbargo = false;
|
||||
|
||||
public ?string $embargoAt = null;
|
||||
|
||||
public string $currentStatus = '';
|
||||
|
||||
public ?int $contentScore = null;
|
||||
|
||||
public ?string $contentTier = null;
|
||||
|
||||
public string $placeholderVariant = '';
|
||||
|
||||
private ?PressRelease $cachedPressRelease = null;
|
||||
|
||||
public function mount(int $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
|
|
@ -72,6 +89,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
);
|
||||
|
||||
$this->currentStatus = $pr->status->value;
|
||||
$this->contentScore = $pr->content_score;
|
||||
$this->contentTier = $pr->content_tier?->value;
|
||||
$this->portal = $pr->portal->value;
|
||||
$this->language = $pr->language;
|
||||
$this->companyId = $pr->company_id;
|
||||
|
|
@ -87,14 +106,27 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
?? $this->defaultContactIdFor((int) $pr->company_id);
|
||||
|
||||
if ($pr->scheduled_at) {
|
||||
// DB-Wert ist UTC; für die Eingabefelder nach Europe/Berlin wandeln.
|
||||
$scheduledAt = $pr->scheduled_at->copy()->setTimezone(PressRelease::DISPLAY_TIMEZONE);
|
||||
$this->publishMode = 'scheduled';
|
||||
$this->scheduledAt = $pr->scheduled_at->format('Y-m-d\TH:i');
|
||||
$this->scheduledAt = $scheduledAt->format('Y-m-d\TH:i');
|
||||
$this->scheduledDate = $scheduledAt->format('Y-m-d');
|
||||
$this->scheduledTime = $scheduledAt->format('H:i');
|
||||
}
|
||||
|
||||
if ($pr->embargo_at) {
|
||||
$this->useEmbargo = true;
|
||||
$this->embargoAt = $pr->embargo_at->format('Y-m-d\TH:i');
|
||||
}
|
||||
$this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($pr->placeholder_variant?->value)->value;
|
||||
}
|
||||
|
||||
#[On('placeholder-selected')]
|
||||
public function setPlaceholderVariant(string $variant): void
|
||||
{
|
||||
$this->placeholderVariant = PressReleasePlaceholder::fromValueOrDefault($variant)->value;
|
||||
}
|
||||
|
||||
#[On('title-image-changed')]
|
||||
public function refreshTitleImage(): void
|
||||
{
|
||||
unset($this->tags, $this->presubmitChecks);
|
||||
}
|
||||
|
||||
public function updatedCompanyId(): void
|
||||
|
|
@ -118,6 +150,35 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
unset($this->tags, $this->presubmitChecks);
|
||||
}
|
||||
|
||||
public function updatedCompanySearch(): void
|
||||
{
|
||||
$this->resetErrorBag('companyId');
|
||||
}
|
||||
|
||||
public function updatedPublishMode(): void
|
||||
{
|
||||
$this->syncScheduledAt();
|
||||
|
||||
if ($this->publishMode === 'now') {
|
||||
$this->scheduledDate = null;
|
||||
$this->scheduledTime = null;
|
||||
$this->scheduledAt = null;
|
||||
$this->resetErrorBag(['scheduledDate', 'scheduledTime', 'scheduledAt']);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedScheduledDate(): void
|
||||
{
|
||||
$this->syncScheduledAt();
|
||||
$this->validateScheduledAtWhenReady();
|
||||
}
|
||||
|
||||
public function updatedScheduledTime(): void
|
||||
{
|
||||
$this->syncScheduledAt();
|
||||
$this->validateScheduledAtWhenReady();
|
||||
}
|
||||
|
||||
public function addTag(string $tag): void
|
||||
{
|
||||
$tag = trim($tag);
|
||||
|
|
@ -159,6 +220,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
* liegt, wird es bei jeder Änderung neu geprüft. So verschwindet ein
|
||||
* roter Hinweis sofort, wenn der User das Feld korrekt ausfüllt — und
|
||||
* der User muss nicht erst auf „Speichern" klicken.
|
||||
*
|
||||
* Die Termin-Synchronisierung liegt vollständig in den spezifischen
|
||||
* `updated{PublishMode,ScheduledDate,ScheduledTime}`-Hooks; hier bleibt
|
||||
* nur die generische Re-Validierung bereits fehlerhafter Felder.
|
||||
*/
|
||||
public function updated(string $property): void
|
||||
{
|
||||
|
|
@ -214,22 +279,99 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
];
|
||||
|
||||
if ($this->publishMode === 'scheduled') {
|
||||
$rules['scheduledAt'] = ['required', 'date', 'after:'.now()->addMinutes(5)->toIso8601String()];
|
||||
$rules['scheduledDate'] = ['required', 'date'];
|
||||
$rules['scheduledTime'] = ['required', 'date_format:H:i'];
|
||||
$rules['scheduledAt'] = [
|
||||
'required',
|
||||
'date',
|
||||
// Termin wird in Europe/Berlin erfasst; deshalb hier zeitzonen-
|
||||
// bewusst prüfen statt über die naive `after:`-Regel.
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
$scheduledAt = $this->scheduledAtUtc();
|
||||
|
||||
if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) {
|
||||
$fail(__('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.'));
|
||||
}
|
||||
},
|
||||
];
|
||||
} else {
|
||||
$rules['scheduledDate'] = ['nullable'];
|
||||
$rules['scheduledTime'] = ['nullable'];
|
||||
$rules['scheduledAt'] = ['nullable'];
|
||||
}
|
||||
|
||||
if ($this->useEmbargo) {
|
||||
$rules['embargoAt'] = ['required', 'date', 'after:'.now()->toIso8601String()];
|
||||
} else {
|
||||
$rules['embargoAt'] = ['nullable'];
|
||||
}
|
||||
$rules['embargoAt'] = ['nullable'];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
protected function syncScheduledAt(): void
|
||||
{
|
||||
if ($this->publishMode !== 'scheduled') {
|
||||
$this->scheduledAt = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (blank($this->scheduledDate) && blank($this->scheduledTime) && filled($this->scheduledAt)) {
|
||||
$scheduledAt = \Carbon\Carbon::parse($this->scheduledAt);
|
||||
$this->scheduledDate = $scheduledAt->format('Y-m-d');
|
||||
$this->scheduledTime = $scheduledAt->format('H:i');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (blank($this->scheduledDate) || blank($this->scheduledTime)) {
|
||||
$this->scheduledAt = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->scheduledAt = "{$this->scheduledDate}T{$this->scheduledTime}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt den in Europe/Berlin erfassten Termin in den UTC-Zeitpunkt,
|
||||
* wie er in der Datenbank gespeichert wird. Null, wenn kein Termin gesetzt.
|
||||
*/
|
||||
protected function scheduledAtUtc(): ?\Carbon\Carbon
|
||||
{
|
||||
if (blank($this->scheduledAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return \Carbon\Carbon::parse($this->scheduledAt, PressRelease::DISPLAY_TIMEZONE)->utc();
|
||||
}
|
||||
|
||||
protected function validateScheduledAtWhenReady(): void
|
||||
{
|
||||
if (blank($this->scheduledAt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resetErrorBag('scheduledAt');
|
||||
|
||||
$scheduledAt = $this->scheduledAtUtc();
|
||||
|
||||
if ($scheduledAt === null || $scheduledAt->lessThanOrEqualTo(now()->addMinutes(5))) {
|
||||
$this->addError('scheduledAt', __('Der Veröffentlichungstermin muss mindestens 5 Minuten in der Zukunft liegen.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->validateOnly('scheduledAt', $this->formRules());
|
||||
} catch (\Illuminate\Validation\ValidationException) {
|
||||
// Termin bleibt invalid — Error-Bag wird automatisch befüllt.
|
||||
}
|
||||
}
|
||||
|
||||
public function save(bool $submitAfterSave = false): void
|
||||
{
|
||||
$this->syncScheduledAt();
|
||||
$this->useEmbargo = false;
|
||||
$this->embargoAt = null;
|
||||
|
||||
try {
|
||||
$this->validate($this->formRules());
|
||||
} catch (\Illuminate\Validation\ValidationException $e) {
|
||||
|
|
@ -278,16 +420,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
'boilerplate_override' => $this->useBoilerplateOverride && trim($this->boilerplateOverride) !== ''
|
||||
? trim($this->boilerplateOverride)
|
||||
: null,
|
||||
'placeholder_variant' => $this->placeholderVariant ?: PressReleasePlaceholder::default()->value,
|
||||
'keywords' => $this->keywords ?: null,
|
||||
'backlink_url' => $this->backlinkUrl ?: null,
|
||||
'scheduled_at' => $this->publishMode === 'scheduled' && $this->scheduledAt
|
||||
? \Carbon\Carbon::parse($this->scheduledAt)
|
||||
: null,
|
||||
'embargo_at' => $this->useEmbargo && $this->embargoAt
|
||||
? \Carbon\Carbon::parse($this->embargoAt)
|
||||
'scheduled_at' => $this->publishMode === 'scheduled'
|
||||
? $this->scheduledAtUtc()
|
||||
: null,
|
||||
'embargo_at' => null,
|
||||
]);
|
||||
|
||||
$contentChanged = $pr->wasChanged(['title', 'text']);
|
||||
|
||||
$pr->contacts()->sync($contact ? [$contact->id] : []);
|
||||
|
||||
if ($submitAfterSave) {
|
||||
|
|
@ -314,6 +457,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
variant: 'success',
|
||||
);
|
||||
} else {
|
||||
// Inhaltliche Änderung einer bereits geprüften/bewerteten PM → neu
|
||||
// prüfen (Re-Check ohne Routing) und neu bewerten. Beim Einreichen
|
||||
// übernimmt das submitForReview.
|
||||
if ($contentChanged) {
|
||||
$service = app(PressReleaseService::class);
|
||||
$fresh = $pr->fresh();
|
||||
$service->reclassifyIfClassified($fresh);
|
||||
$service->rescoreIfScored($fresh);
|
||||
}
|
||||
|
||||
Flux::toast(
|
||||
heading: __('Gespeichert'),
|
||||
text: __('Deine Änderungen sind gesichert.'),
|
||||
|
|
@ -333,17 +486,24 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
{
|
||||
$user = auth()->user();
|
||||
$context = app(CustomerCompanyContext::class);
|
||||
$myCompanies = $context->companiesFor($user);
|
||||
$selectedCompany = $this->selectedCompany();
|
||||
$companyOptions = $context->searchCompaniesFor(
|
||||
$user,
|
||||
$this->companySearch,
|
||||
$this->companyId ? (int) $this->companyId : null,
|
||||
10,
|
||||
);
|
||||
|
||||
$categories = Category::query()
|
||||
->with('translations')
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
$pressRelease = $this->getMyPR()->load('images');
|
||||
$cover = app(PressReleaseCoverImage::class);
|
||||
|
||||
return [
|
||||
'myCompanies' => $myCompanies,
|
||||
'companyOptions' => $companyOptions,
|
||||
'categories' => $categories,
|
||||
'selectedCompany' => $selectedCompany,
|
||||
'selectedCompanyContacts' => $selectedCompany
|
||||
|
|
@ -351,6 +511,10 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
: Contact::query()->whereRaw('0 = 1')->get(),
|
||||
'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'),
|
||||
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
|
||||
'coverUrl' => $cover->coverUrl($pressRelease, 'cover'),
|
||||
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pressRelease),
|
||||
'quotaTotal' => (int) $user->press_release_quota,
|
||||
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -453,7 +617,9 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
|
||||
private function getMyPR(): PressRelease
|
||||
{
|
||||
return PressRelease::withoutGlobalScopes()
|
||||
// Pro Livewire-Request memoisiert: mount(), with() und save() greifen
|
||||
// sonst jeweils mit einer eigenen Query auf dieselbe PM zu.
|
||||
return $this->cachedPressRelease ??= PressRelease::withoutGlobalScopes()
|
||||
->where('user_id', auth()->id())
|
||||
->findOrFail($this->id);
|
||||
}
|
||||
|
|
@ -495,7 +661,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-8" x-data="{ tagInput: '' }">
|
||||
<div class="space-y-8 pr-editor-shell" x-data="{ tagInput: '' }">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
||||
<div class="min-w-0">
|
||||
|
|
@ -518,17 +684,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<flux:button variant="ghost" icon="eye" href="{{ route('me.press-releases.show', $id) }}" wire:navigate>
|
||||
<flux:button variant="filled" icon="eye" href="{{ route('me.press-releases.show', $id) }}" wire:navigate>
|
||||
{{ __('Vorschau / Detail') }}
|
||||
</flux:button>
|
||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||||
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||||
{{ __('Zur Liste') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{-- ============== 2-COLUMN GRID ============== --}}
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div class="grid gap-6 pr-editor-layout">
|
||||
|
||||
{{-- =================== LINKS: SCHREIBFLÄCHE =================== --}}
|
||||
<div class="space-y-6 min-w-0">
|
||||
|
|
@ -537,18 +703,42 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
<section class="panel">
|
||||
<div class="p-4 flex flex-wrap items-center gap-4">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">{{ __('Für Firma') }}</span>
|
||||
<flux:select wire:model.live="companyId" class="!w-auto min-w-[260px]">
|
||||
<option value="">{{ __('Bitte wählen…') }}</option>
|
||||
@foreach ($myCompanies as $c)
|
||||
<option value="{{ $c->id }}">{{ $c->name }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<div class="min-w-[260px]">
|
||||
<flux:select
|
||||
wire:model.live="companyId"
|
||||
variant="combobox"
|
||||
:filter="false"
|
||||
clearable
|
||||
placeholder="{{ __('Firma suchen…') }}"
|
||||
>
|
||||
<x-slot name="input">
|
||||
<flux:select.input
|
||||
wire:model.live.debounce.300ms="companySearch"
|
||||
placeholder="{{ __('Name eingeben…') }}"
|
||||
/>
|
||||
</x-slot>
|
||||
@foreach ($companyOptions as $company)
|
||||
<flux:select.option :value="$company->id" wire:key="company-option-{{ $company->id }}">
|
||||
{{ $company->name }}{{ $company->portal ? ' ('.$company->portal->abbreviation().')' : '' }}
|
||||
</flux:select.option>
|
||||
@endforeach
|
||||
<x-slot name="empty">
|
||||
<flux:select.option.empty>
|
||||
@if (blank(trim($companySearch)))
|
||||
{{ __('Keine Firma verfügbar.') }}
|
||||
@else
|
||||
{{ __('Keine Firma gefunden.') }}
|
||||
@endif
|
||||
</flux:select.option.empty>
|
||||
</x-slot>
|
||||
</flux:select>
|
||||
</div>
|
||||
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
|
||||
{{ __('Boilerplate und Pressekontakt werden bei Wechsel angepasst.') }}
|
||||
</span>
|
||||
<span class="flex-1"></span>
|
||||
@if ($selectedCompany)
|
||||
<flux:button size="sm" variant="ghost" icon="building-office"
|
||||
<flux:button size="sm" variant="filled" icon="building-office"
|
||||
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
|
||||
{{ __('Firmenprofil') }}
|
||||
</flux:button>
|
||||
|
|
@ -655,7 +845,32 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{{-- 5) MEDIEN — Image-Manager direkt eingebunden --}}
|
||||
@if ($coverIsPlaceholder)
|
||||
{{-- 5) TITELBILD-PLATZHALTER --}}
|
||||
<section class="panel">
|
||||
<div class="p-5">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<span class="pr-form-label" style="margin-bottom:0;">{{ __('Titelbild-Platzhalter') }}</span>
|
||||
<flux:modal.trigger name="placeholder-picker">
|
||||
<flux:button size="xs" variant="filled" icon="swatch">
|
||||
{{ __('Platzhalter wählen') }}
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
<x-portal.press-release-placeholder
|
||||
:variant="$placeholderVariant"
|
||||
:title="$title ?: null"
|
||||
class="mx-auto aspect-[1280/580] w-full max-w-[1280px] rounded-[6px] border border-[color:var(--color-bg-rule)]" />
|
||||
<p class="mt-2 text-[11.5px] text-[color:var(--color-ink-3)] m-0">
|
||||
{{ __('Dieser Platzhalter wird angezeigt, bis ein eigenes Titelbild hochgeladen ist.') }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<livewire:components.press-release-placeholder-picker :current="$placeholderVariant" />
|
||||
@endif
|
||||
|
||||
{{-- 6) 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
|
||||
|
|
@ -718,7 +933,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
{{-- /Schreibfläche --}}
|
||||
|
||||
{{-- =================== RECHTS: SETTINGS-SIDEBAR =================== --}}
|
||||
<aside class="space-y-4 lg:sticky lg:top-4 self-start">
|
||||
<aside class="space-y-4 pr-editor-side self-start">
|
||||
|
||||
{{-- Status & Absenden --}}
|
||||
<article class="panel" style="border-color:var(--color-hub);">
|
||||
|
|
@ -766,17 +981,17 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
@endforeach
|
||||
</div>
|
||||
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="primary"
|
||||
icon="paper-airplane"
|
||||
class="w-full"
|
||||
wire:click="saveAndSubmit"
|
||||
wire:confirm="{{ __('Änderungen speichern und zur Prüfung einreichen?') }}"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
{{ $currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & zur Prüfung') }}
|
||||
</flux:button>
|
||||
<flux:modal.trigger name="confirm-submit-review">
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="primary"
|
||||
icon="paper-airplane"
|
||||
class="w-full"
|
||||
wire:loading.attr="disabled"
|
||||
>
|
||||
{{ $currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & zur Prüfung') }}
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
|
||||
{{ __('Warnungen blockieren nicht. Pflichtfelder blockieren. Die Redaktion prüft typ. innerhalb von 24h.') }}
|
||||
</p>
|
||||
|
|
@ -784,7 +999,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
<hr class="border-0 border-t border-[color:var(--color-bg-rule)] my-3" />
|
||||
<flux:button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="filled"
|
||||
icon="bookmark"
|
||||
class="w-full"
|
||||
wire:click="save"
|
||||
|
|
@ -795,6 +1010,42 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
</div>
|
||||
</article>
|
||||
|
||||
{{-- Content-Score (Qualitäts-Feedback während des Schreibens) --}}
|
||||
@if (! is_null($contentScore))
|
||||
@php
|
||||
$tierEnum = $contentTier ? \App\Enums\PressReleaseContentTier::from($contentTier) : null;
|
||||
$tiers = config('scoring.content_score.tiers');
|
||||
$nextThreshold = $contentScore < (int) $tiers['gepruft']
|
||||
? (int) $tiers['gepruft']
|
||||
: ($contentScore < (int) $tiers['hochwertig'] ? (int) $tiers['hochwertig'] : null);
|
||||
@endphp
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Qualität') }}</span>
|
||||
@if ($tierEnum)
|
||||
<span class="badge {{ $tierEnum === \App\Enums\PressReleaseContentTier::Hochwertig ? 'ok' : ($tierEnum === \App\Enums\PressReleaseContentTier::Geprueft ? 'hub' : 'muted') }}">{{ $tierEnum->label() }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="text-[26px] font-bold leading-none text-[color:var(--color-ink)]">
|
||||
{{ $contentScore }}<span class="text-[14px] font-medium text-[color:var(--color-ink-3)]">/100</span>
|
||||
</div>
|
||||
@if ($nextThreshold)
|
||||
<p class="text-[12px] text-[color:var(--color-ink-2)] mt-2 m-0">
|
||||
{{ __('Noch :points Punkte bis zur nächsten Stufe.', ['points' => $nextThreshold - $contentScore]) }}
|
||||
</p>
|
||||
@else
|
||||
<p class="text-[12px] text-[color:var(--color-ink-2)] mt-2 m-0">
|
||||
{{ __('Höchste Stufe erreicht.') }}
|
||||
</p>
|
||||
@endif
|
||||
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-2 leading-[1.45] m-0">
|
||||
{{ __('Der Score wird nach dem Speichern automatisch neu berechnet.') }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
@endif
|
||||
|
||||
{{-- Kategorie (Pflichtfeld - prominent direkt unter Status) --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
|
|
@ -856,7 +1107,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
{{ __('Diese Firma hat noch keine Pressekontakte.') }}
|
||||
</p>
|
||||
@if ($selectedCompany)
|
||||
<flux:button size="sm" variant="ghost" icon="plus" class="w-full"
|
||||
<flux:button size="sm" variant="filled" icon="plus" class="w-full"
|
||||
href="{{ route('me.press-kits.show', $selectedCompany->id) }}" wire:navigate>
|
||||
{{ __('Kontakt im Firmenprofil anlegen') }}
|
||||
</flux:button>
|
||||
|
|
@ -986,39 +1237,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
</label>
|
||||
|
||||
@if ($publishMode === 'scheduled')
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Veröffentlichungstermin') }}</flux:label>
|
||||
<flux:input
|
||||
wire:model.live="scheduledAt"
|
||||
type="datetime-local"
|
||||
:min="now()->addMinutes(5)->format('Y-m-d\\TH:i')"
|
||||
/>
|
||||
<flux:description>{{ __('Frühestens 5 Min. in der Zukunft.') }}</flux:description>
|
||||
<flux:error name="scheduledAt" />
|
||||
</flux:field>
|
||||
@endif
|
||||
|
||||
<div class="border-t pt-3" style="border-color: var(--color-line);">
|
||||
<flux:switch
|
||||
wire:model.live="useEmbargo"
|
||||
:label="__('Sperrfrist (Embargo) setzen')"
|
||||
/>
|
||||
<p class="text-[11px] text-[color:var(--color-ink-3)] mt-1 leading-tight">
|
||||
{{ __('Die PM ist intern frei, aber öffentlich erst ab gewähltem Zeitpunkt sichtbar.') }}
|
||||
</p>
|
||||
|
||||
@if ($useEmbargo)
|
||||
<flux:field class="mt-3">
|
||||
<flux:label>{{ __('Sperrfrist bis') }}</flux:label>
|
||||
<flux:input
|
||||
wire:model.live="embargoAt"
|
||||
type="datetime-local"
|
||||
:min="now()->format('Y-m-d\\TH:i')"
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Datum') }}</flux:label>
|
||||
<flux:date-picker
|
||||
wire:model.live="scheduledDate"
|
||||
type="input"
|
||||
:placeholder="__('Datum wählen')"
|
||||
with-today
|
||||
/>
|
||||
<flux:error name="embargoAt" />
|
||||
<flux:error name="scheduledDate" />
|
||||
</flux:field>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Uhrzeit') }}</flux:label>
|
||||
<flux:time-picker
|
||||
wire:model.live="scheduledTime"
|
||||
type="input"
|
||||
:placeholder="__('Uhrzeit wählen')"
|
||||
/>
|
||||
<flux:error name="scheduledTime" />
|
||||
</flux:field>
|
||||
</div>
|
||||
<flux:error name="scheduledAt" />
|
||||
<p class="text-[11px] text-[color:var(--color-ink-3)] leading-tight">{{ __('Frühestens 5 Min. in der Zukunft.') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
|
@ -1061,4 +1304,12 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
|
|||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Einreichungs-Modal (öffnet über „Speichern & zur Prüfung") --}}
|
||||
<x-press-release-submit-modal
|
||||
name="confirm-submit-review"
|
||||
action="saveAndSubmit"
|
||||
:confirm-label="$currentStatus === 'rejected' ? __('Speichern & erneut einreichen') : __('Speichern & einreichen')"
|
||||
:quota-total="$quotaTotal"
|
||||
:quota-remaining="$quotaRemaining" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -505,23 +505,23 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
|
|||
@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->scheduled_at->format('d.m. H:i') }}</span>
|
||||
<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->embargo_at->format('d.m.') }}</span>
|
||||
<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="ghost" icon="eye"
|
||||
<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="ghost" icon="pencil"
|
||||
<flux:button size="sm" variant="filled" icon="pencil"
|
||||
href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate />
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use App\Enums\PressReleaseStatus;
|
|||
use App\Models\PressRelease;
|
||||
use App\Services\Auth\MagicLinkGenerator;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseCoverImage;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Flux\Flux;
|
||||
use Livewire\Attributes\Layout;
|
||||
|
|
@ -35,6 +36,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
try {
|
||||
app(PressReleaseService::class)->submitForReview($pr);
|
||||
} catch (BlacklistViolationException $e) {
|
||||
Flux::modal('confirm-submit-review')->close();
|
||||
|
||||
Flux::toast(
|
||||
heading: __('Automatisch abgelehnt'),
|
||||
text: __('Unzulässiges Wort gefunden: ":word". Bitte überarbeiten.', ['word' => $e->word]),
|
||||
|
|
@ -45,6 +48,8 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
return;
|
||||
}
|
||||
|
||||
Flux::modal('confirm-submit-review')->close();
|
||||
|
||||
Flux::toast(
|
||||
heading: __('Eingereicht'),
|
||||
text: __('Die Redaktion prüft die Pressemitteilung typischerweise innerhalb von 24 h.'),
|
||||
|
|
@ -78,9 +83,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
->firstWhere(fn ($log) => $log->to_status?->value === 'rejected');
|
||||
}
|
||||
|
||||
$cover = app(PressReleaseCoverImage::class);
|
||||
$user = auth()->user();
|
||||
|
||||
return [
|
||||
'pr' => $pr,
|
||||
'categoryName' => $categoryName,
|
||||
'coverUrl' => $cover->coverUrl($pr, 'cover'),
|
||||
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
|
||||
'quotaTotal' => (int) $user->press_release_quota,
|
||||
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
|
||||
'canEdit' => auth()->user()->can('update', $pr)
|
||||
&& in_array($pr->status->value, ['draft', 'rejected']),
|
||||
'latestRejection' => $latestRejection,
|
||||
|
|
@ -133,6 +145,11 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<span class="badge hub dot">{{ __('User Backend') }}</span>
|
||||
<span class="eyebrow muted">{{ __('Mein Bereich · Pressemitteilung') }}</span>
|
||||
<span @class(['badge', $statusClass])>{{ $pr->status->label() }}</span>
|
||||
@if ($pr->content_tier?->isPubliclyBadged())
|
||||
<span class="badge {{ $pr->content_tier === \App\Enums\PressReleaseContentTier::Hochwertig ? 'ok' : 'hub' }}">
|
||||
{{ $pr->content_tier === \App\Enums\PressReleaseContentTier::Hochwertig ? '★ ' : '✓ ' }}{{ $pr->content_tier->label() }}
|
||||
</span>
|
||||
@endif
|
||||
<span class="badge hub">{{ strtoupper($pr->language) }}</span>
|
||||
</div>
|
||||
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
||||
|
|
@ -154,19 +171,35 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
@if ($canEdit)
|
||||
<flux:button variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
|
||||
<flux:button variant="filled" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
|
||||
{{ __('Bearbeiten') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
<flux:button variant="ghost" icon="link" wire:click="generateShareLink">
|
||||
<flux:button variant="filled" icon="link" wire:click="generateShareLink">
|
||||
{{ __('Vorschau-Link') }}
|
||||
</flux:button>
|
||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||||
<flux:button variant="filled" icon="arrow-left" href="{{ route('me.press-releases.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{-- ============== TITELBILD (Hero) ============== --}}
|
||||
{{-- Harte Obergrenze 1280x580 px: Container deckelt Breite und Seitenverhältnis,
|
||||
damit das Bild auf großen Screens nicht über die Detailgröße hinauswächst. --}}
|
||||
<article class="panel overflow-hidden mx-auto w-full max-w-[1280px]">
|
||||
<div class="relative aspect-[1280/580] w-full">
|
||||
<img src="{{ $coverUrl }}" alt="{{ $pr->title }}"
|
||||
class="absolute inset-0 h-full w-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
@if ($coverIsPlaceholder)
|
||||
<div class="flex 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)]">
|
||||
<flux:icon.photo variant="micro" class="size-3.5" />
|
||||
<span>{{ __('Platzhalter-Titelbild — laden Sie im Editor ein eigenes Bild hoch.') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
|
||||
{{-- ============== SHARE-LINK ERFOLG ============== --}}
|
||||
@if ($shareUrl)
|
||||
<article class="panel" style="border-color:var(--color-ok);">
|
||||
|
|
@ -224,14 +257,15 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
</p>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
@if ($canEdit)
|
||||
<flux:button variant="ghost" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
|
||||
<flux:button variant="filled" icon="pencil" href="{{ route('me.press-releases.edit', $pr->id) }}" wire:navigate>
|
||||
{{ __('Bearbeiten') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
<flux:button type="button" variant="primary" wire:click="submitForReview"
|
||||
wire:confirm="{{ __('Pressemitteilung zur Prüfung einreichen?') }}">
|
||||
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut einreichen') : __('Zur Prüfung einreichen') }}
|
||||
</flux:button>
|
||||
<flux:modal.trigger name="confirm-submit-review">
|
||||
<flux:button type="button" variant="primary">
|
||||
{{ $pr->status === PressReleaseStatus::Rejected ? __('Erneut einreichen') : __('Zur Prüfung einreichen') }}
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -261,7 +295,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Zugeordnete Pressekontakte') }}</span>
|
||||
@if ($pr->company)
|
||||
<flux:button size="sm" variant="ghost" icon="building-office" href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate>
|
||||
<flux:button size="sm" variant="filled" icon="building-office" href="{{ route('me.press-kits.show', $pr->company->id) }}" wire:navigate>
|
||||
{{ __('Firma') }}
|
||||
</flux:button>
|
||||
@endif
|
||||
|
|
@ -341,7 +375,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Geplante Veröffentlichung') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $pr->scheduled_at->format('d.m.Y H:i') }}
|
||||
{{ $pr->scheduledAtLocal()->format('d.m.Y H:i') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -349,7 +383,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold">{{ __('Sperrfrist bis') }}</div>
|
||||
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] mt-1">
|
||||
{{ $pr->embargo_at->format('d.m.Y H:i') }}
|
||||
{{ $pr->embargoAtLocal()->format('d.m.Y H:i') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -434,6 +468,16 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
|
|||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== VERÖFFENTLICHUNGS-MODAL ============== --}}
|
||||
@if ($pr->status === PressReleaseStatus::Draft || $pr->status === PressReleaseStatus::Rejected)
|
||||
<x-press-release-submit-modal
|
||||
name="confirm-submit-review"
|
||||
action="submitForReview"
|
||||
:confirm-label="__('Veröffentlichung anfordern')"
|
||||
:quota-total="$quotaTotal"
|
||||
:quota-remaining="$quotaRemaining" />
|
||||
@endif
|
||||
|
||||
{{-- ============== BOILERPLATE-OVERRIDE ============== --}}
|
||||
@if ($pr->boilerplate_override)
|
||||
<article class="panel">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue