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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue