520 lines
21 KiB
PHP
520 lines
21 KiB
PHP
<?php
|
|
|
|
use App\Enums\Portal;
|
|
use App\Enums\PressReleaseStatus;
|
|
use App\Models\Category;
|
|
use App\Models\Company;
|
|
use App\Models\PressRelease;
|
|
use App\Services\Admin\AdminPerformanceCache;
|
|
use App\Services\PressRelease\BlacklistViolationException;
|
|
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
|
|
use App\Services\PressRelease\PressReleaseService;
|
|
use Flux\Flux;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\Locked;
|
|
use Livewire\Attributes\Title;
|
|
use Livewire\Volt\Component;
|
|
|
|
new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] class extends Component
|
|
{
|
|
#[Locked]
|
|
public int $id;
|
|
|
|
public string $portal = '';
|
|
|
|
public string $language = 'de';
|
|
|
|
public int|string|null $companyId = null;
|
|
|
|
public string $companySearch = '';
|
|
|
|
public int|string|null $categoryId = null;
|
|
|
|
public string $title = '';
|
|
|
|
public string $text = '';
|
|
|
|
public string $keywords = '';
|
|
|
|
public string $backlinkUrl = '';
|
|
|
|
public bool $noExport = false;
|
|
|
|
public string $currentStatus = '';
|
|
|
|
public string $targetStatus = '';
|
|
|
|
public function mount(int $id): void
|
|
{
|
|
$this->id = $id;
|
|
|
|
$pr = PressRelease::withoutGlobalScopes()->findOrFail($id);
|
|
|
|
$this->portal = $pr->portal->value;
|
|
$this->language = $pr->language;
|
|
$this->companyId = $pr->company_id;
|
|
$this->categoryId = $pr->category_id;
|
|
$this->title = $pr->title;
|
|
$this->text = $pr->text;
|
|
$this->keywords = $pr->keywords ?? '';
|
|
$this->backlinkUrl = $pr->backlink_url ?? '';
|
|
$this->noExport = $pr->no_export;
|
|
$this->currentStatus = $pr->status->value;
|
|
$this->targetStatus = $this->currentStatus;
|
|
}
|
|
|
|
public function updatedCompanySearch(): void
|
|
{
|
|
$this->resetErrorBag('companyId');
|
|
}
|
|
|
|
public function save(): void
|
|
{
|
|
$this->validate([
|
|
'portal' => ['required', Rule::in(array_map(fn (Portal $p) => $p->value, Portal::cases()))],
|
|
'language' => ['required', Rule::in(['de', 'en'])],
|
|
'companyId' => ['required', 'integer', Rule::exists('companies', 'id')],
|
|
'categoryId' => ['required', 'integer', Rule::exists('categories', 'id')],
|
|
'title' => ['required', 'string', 'min:5', 'max:255'],
|
|
'text' => ['required', 'string', 'min:50'],
|
|
'keywords' => ['nullable', 'string', 'max:255'],
|
|
'backlinkUrl' => ['nullable', 'url', 'max:255'],
|
|
]);
|
|
|
|
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
|
|
|
|
if ($pr->title !== $this->title || $pr->portal !== $this->portal || $pr->language !== $this->language) {
|
|
$slug = $pr->generateUniqueSlug($this->title, [
|
|
'portal' => $this->portal,
|
|
'language' => $this->language,
|
|
]);
|
|
} else {
|
|
$slug = $pr->slug;
|
|
}
|
|
|
|
$cleanText = app(PressReleaseHtmlSanitizer::class)->clean($this->text);
|
|
|
|
$pr->update([
|
|
'portal' => $this->portal,
|
|
'language' => $this->language,
|
|
'company_id' => (int) $this->companyId,
|
|
'category_id' => (int) $this->categoryId,
|
|
'title' => $this->title,
|
|
'slug' => $slug,
|
|
'text' => $cleanText,
|
|
'keywords' => $this->keywords ?: null,
|
|
'backlink_url' => $this->backlinkUrl ?: null,
|
|
'no_export' => $this->noExport,
|
|
]);
|
|
|
|
session()->flash('success', __('Pressemitteilung gespeichert.'));
|
|
}
|
|
|
|
public function submitForReview(): void
|
|
{
|
|
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
|
|
|
|
try {
|
|
app(PressReleaseService::class)->submitForReview($pr);
|
|
} catch (BlacklistViolationException $e) {
|
|
$this->currentStatus = PressReleaseStatus::Rejected->value;
|
|
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
|
|
|
|
return;
|
|
}
|
|
|
|
$this->currentStatus = PressReleaseStatus::Review->value;
|
|
session()->flash('success', __('Zur Prüfung eingereicht.'));
|
|
}
|
|
|
|
public function publish(): void
|
|
{
|
|
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
|
|
|
|
try {
|
|
app(PressReleaseService::class)->publish($pr);
|
|
} catch (BlacklistViolationException $e) {
|
|
$this->currentStatus = PressReleaseStatus::Rejected->value;
|
|
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
|
|
|
|
return;
|
|
}
|
|
|
|
$this->currentStatus = PressReleaseStatus::Published->value;
|
|
session()->flash('success', __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.'));
|
|
}
|
|
|
|
public function reject(): void
|
|
{
|
|
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
|
|
app(PressReleaseService::class)->reject($pr);
|
|
$this->currentStatus = PressReleaseStatus::Rejected->value;
|
|
session()->flash('success', __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.'));
|
|
}
|
|
|
|
public function backToDraft(): void
|
|
{
|
|
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
|
|
app(PressReleaseService::class)->backToDraft($pr);
|
|
$this->currentStatus = PressReleaseStatus::Draft->value;
|
|
session()->flash('success', __('Zurück auf Entwurf gesetzt.'));
|
|
}
|
|
|
|
public function archive(): void
|
|
{
|
|
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
|
|
app(PressReleaseService::class)->archive($pr);
|
|
$this->currentStatus = PressReleaseStatus::Archived->value;
|
|
$this->targetStatus = $this->currentStatus;
|
|
session()->flash('success', __('Pressemitteilung archiviert.'));
|
|
}
|
|
|
|
public function changeStatus(): void
|
|
{
|
|
$this->validate([
|
|
'targetStatus' => ['required', Rule::in(array_map(fn (PressReleaseStatus $status) => $status->value, PressReleaseStatus::cases()))],
|
|
]);
|
|
|
|
if ($this->targetStatus === $this->currentStatus) {
|
|
$this->addError('targetStatus', __('Bitte wähle einen anderen Status aus.'));
|
|
|
|
return;
|
|
}
|
|
|
|
$status = PressReleaseStatus::from($this->targetStatus);
|
|
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
|
|
|
|
app(PressReleaseService::class)->changeStatusFromAdmin($pr, $status);
|
|
|
|
$this->currentStatus = $status->value;
|
|
$this->targetStatus = $status->value;
|
|
|
|
session()->flash('success', __('Status wurde auf ":status" geändert.', ['status' => $status->label()]));
|
|
Flux::modal('confirm-status-change')->close();
|
|
}
|
|
|
|
public function deletePressRelease(): void
|
|
{
|
|
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
|
|
$wasPublished = $pr->status === PressReleaseStatus::Published;
|
|
|
|
app(PressReleaseService::class)->deleteFromAdmin($pr);
|
|
|
|
session()->flash('success', $wasPublished
|
|
? __('Pressemitteilung wurde archiviert und der Inhalt durch den voreingestellten Ersatztext ersetzt.')
|
|
: __('Pressemitteilung wurde gelöscht.'));
|
|
|
|
$this->redirect(route('admin.press-releases.index'), navigate: true);
|
|
}
|
|
|
|
public function with(): array
|
|
{
|
|
$term = trim($this->companySearch);
|
|
|
|
$companies = Company::withoutGlobalScopes()
|
|
->when(filled($term), function ($q) use ($term): void {
|
|
if ($this->supportsFullTextSearch($term)) {
|
|
$q->whereFullText(['name', 'email', 'slug'], $term);
|
|
|
|
return;
|
|
}
|
|
|
|
$q->where('name', 'like', '%'.$term.'%')->orWhere('slug', 'like', '%'.$term.'%');
|
|
})
|
|
->when(blank($term) && $this->companyId, fn ($q) => $q->whereIn('id', [(int) $this->companyId]))
|
|
->when(blank($term) && ! $this->companyId, fn ($q) => $q->whereRaw('0 = 1'))
|
|
->orderBy('name')
|
|
->limit(50)
|
|
->get(['id', 'name']);
|
|
|
|
$statusEnum = PressReleaseStatus::tryFrom($this->currentStatus);
|
|
|
|
return [
|
|
'companies' => $companies,
|
|
'categories' => $this->categoryOptions(),
|
|
'portalOptions' => array_filter(Portal::cases(), fn (Portal $p) => $p !== Portal::Both),
|
|
'statusOptions' => PressReleaseStatus::cases(),
|
|
'statusEnum' => $statusEnum,
|
|
'targetStatusEnum' => PressReleaseStatus::tryFrom($this->targetStatus),
|
|
'statusColor' => match ($this->currentStatus) {
|
|
'published' => 'green',
|
|
'review' => 'yellow',
|
|
'rejected' => 'red',
|
|
'archived' => 'blue',
|
|
default => 'zinc',
|
|
},
|
|
];
|
|
}
|
|
|
|
private function categoryOptions(): Collection
|
|
{
|
|
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::ActiveCategoryOptions, AdminPerformanceCache::OptionsTtl, fn () => Category::query()
|
|
->with('translations')
|
|
->where('is_active', true)
|
|
->orderBy('id')
|
|
->get());
|
|
}
|
|
|
|
private function supportsFullTextSearch(string $term): bool
|
|
{
|
|
return mb_strlen($term) >= 3
|
|
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
|
|
}
|
|
|
|
}; ?>
|
|
|
|
<div class="space-y-8">
|
|
@php
|
|
$statusClass = match ($currentStatus) {
|
|
'published' => 'ok',
|
|
'review' => 'warn',
|
|
'rejected' => 'err',
|
|
default => 'hub',
|
|
};
|
|
@endphp
|
|
|
|
@if (session('success'))
|
|
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
|
|
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
|
|
{{ session('success') }}
|
|
</div>
|
|
@endif
|
|
@if (session('error'))
|
|
<div class="px-4 py-3 rounded-[5px] border text-[12.5px]
|
|
bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-loss)]">
|
|
{{ session('error') }}
|
|
</div>
|
|
@endif
|
|
|
|
{{-- ============== PAGE HEADER ============== --}}
|
|
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
|
<div class="min-w-0">
|
|
<div class="flex items-center gap-3 mb-3 flex-wrap">
|
|
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
|
|
<span class="eyebrow muted">{{ __('Content · Bearbeiten') }}</span>
|
|
<span @class(['badge', $statusClass])>{{ $statusEnum?->label() ?? $currentStatus }}</span>
|
|
<span class="badge hub">ID {{ $id }}</span>
|
|
</div>
|
|
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
|
{{ __('Pressemitteilung bearbeiten') }}
|
|
</h1>
|
|
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
|
{{ __('Inhalt, Metadaten und Status der PM aktualisieren. Änderungen werden sofort wirksam.') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
|
|
{{ __('Zurück') }}
|
|
</flux:button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="grid gap-6 lg:grid-cols-[1fr,320px]">
|
|
{{-- ============== HAUPTINHALT ============== --}}
|
|
<div class="space-y-6">
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Inhalt') }}</span>
|
|
</div>
|
|
<div class="p-5 space-y-4">
|
|
<flux:field>
|
|
<flux:label>{{ __('Titel') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
|
<flux:input wire:model="title" />
|
|
<flux:error name="title" />
|
|
</flux:field>
|
|
|
|
<flux:field>
|
|
<flux:label>{{ __('Text') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
|
<flux:editor
|
|
wire:model="text"
|
|
toolbar="heading | bold italic | bullet ordered blockquote | link | undo redo"
|
|
/>
|
|
<flux:error name="text" />
|
|
</flux:field>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('SEO & Links') }}</span>
|
|
</div>
|
|
<div class="p-5 space-y-4">
|
|
<flux:field>
|
|
<flux:label>{{ __('Stichwörter') }}</flux:label>
|
|
<flux:input wire:model="keywords" placeholder="{{ __('Kommagetrennt…') }}" />
|
|
</flux:field>
|
|
|
|
<flux:field>
|
|
<flux:label>{{ __('Backlink-URL') }}</flux:label>
|
|
<flux:input wire:model="backlinkUrl" type="url" placeholder="https://…" />
|
|
<flux:error name="backlinkUrl" />
|
|
</flux:field>
|
|
</div>
|
|
</article>
|
|
|
|
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
|
|
</div>
|
|
|
|
{{-- ============== SIDEBAR ============== --}}
|
|
<aside class="space-y-4">
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Status-Aktionen') }}</span>
|
|
<span @class(['badge', $statusClass])>{{ $statusEnum?->label() ?? $currentStatus }}</span>
|
|
</div>
|
|
<div class="p-5 space-y-3">
|
|
<flux:field>
|
|
<flux:label>{{ __('Neuer Status') }}</flux:label>
|
|
<flux:select wire:model.live="targetStatus">
|
|
@foreach ($statusOptions as $statusOption)
|
|
<option value="{{ $statusOption->value }}">
|
|
{{ $statusOption->label() }}{{ $statusOption->value === $currentStatus ? ' (aktuell)' : '' }}
|
|
</option>
|
|
@endforeach
|
|
</flux:select>
|
|
<flux:error name="targetStatus" />
|
|
</flux:field>
|
|
|
|
<flux:modal.trigger name="confirm-status-change">
|
|
<flux:button type="button" variant="primary" class="w-full">
|
|
{{ __('Status wechseln') }}
|
|
</flux:button>
|
|
</flux:modal.trigger>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Metadaten') }}</span>
|
|
</div>
|
|
<div class="p-5 space-y-4">
|
|
<flux:field>
|
|
<flux:label>{{ __('Portal') }}</flux:label>
|
|
<flux:select wire:model="portal">
|
|
@foreach ($portalOptions as $p)
|
|
<option value="{{ $p->value }}">{{ $p->label() }}</option>
|
|
@endforeach
|
|
</flux:select>
|
|
</flux:field>
|
|
|
|
<flux:field>
|
|
<flux:label>{{ __('Sprache') }}</flux:label>
|
|
<flux:select wire:model="language">
|
|
<option value="de">Deutsch</option>
|
|
<option value="en">English</option>
|
|
</flux:select>
|
|
</flux:field>
|
|
|
|
<flux:field>
|
|
<flux:label>{{ __('Firma') }}</flux:label>
|
|
<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 ($companies as $company)
|
|
<flux:select.option :value="$company->id" wire:key="{{ $company->id }}">
|
|
{{ $company->name }}
|
|
</flux:select.option>
|
|
@endforeach
|
|
<x-slot name="empty">
|
|
<flux:select.option.empty>
|
|
@if (blank(trim($companySearch)))
|
|
{{ __('Mindestens 1 Zeichen eingeben…') }}
|
|
@else
|
|
{{ __('Keine Firma gefunden.') }}
|
|
@endif
|
|
</flux:select.option.empty>
|
|
</x-slot>
|
|
</flux:select>
|
|
<flux:error name="companyId" />
|
|
</flux:field>
|
|
|
|
<flux:field>
|
|
<flux:label>{{ __('Kategorie') }}</flux:label>
|
|
<flux:select wire:model="categoryId">
|
|
<option value="">{{ __('Bitte wählen…') }}</option>
|
|
@foreach ($categories as $cat)
|
|
@php($catName = $cat->translations->firstWhere('locale', 'de')?->name ?? $cat->id)
|
|
<option value="{{ $cat->id }}">{{ $catName }}</option>
|
|
@endforeach
|
|
</flux:select>
|
|
<flux:error name="categoryId" />
|
|
</flux:field>
|
|
|
|
<flux:checkbox wire:model="noExport" label="{{ __('Kein Export') }}" />
|
|
</div>
|
|
</article>
|
|
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Aktionen') }}</span>
|
|
</div>
|
|
<div class="p-5 space-y-2">
|
|
<flux:button type="button" variant="primary" class="w-full" wire:click="save">
|
|
{{ __('Änderungen speichern') }}
|
|
</flux:button>
|
|
<flux:modal.trigger name="confirm-delete-press-release">
|
|
<flux:button type="button" variant="danger" icon="trash" class="w-full">
|
|
{{ __('Pressemitteilung löschen') }}
|
|
</flux:button>
|
|
</flux:modal.trigger>
|
|
</div>
|
|
</article>
|
|
</aside>
|
|
</div>
|
|
|
|
<flux:modal name="confirm-status-change" class="max-w-lg">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">{{ __('Status wirklich wechseln?') }}</flux:heading>
|
|
<flux:subheading>
|
|
{{ __('Aktuell: :current. Neuer Status: :target.', [
|
|
'current' => $statusEnum?->label() ?? $currentStatus,
|
|
'target' => $targetStatusEnum?->label() ?? $targetStatus,
|
|
]) }}
|
|
</flux:subheading>
|
|
</div>
|
|
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
|
|
<flux:modal.close>
|
|
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
|
|
</flux:modal.close>
|
|
<flux:button variant="primary" wire:click="changeStatus">{{ __('Status ändern') }}</flux:button>
|
|
</div>
|
|
</div>
|
|
</flux:modal>
|
|
|
|
<flux:modal name="confirm-delete-press-release" class="max-w-lg">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">{{ __('Pressemitteilung löschen?') }}</flux:heading>
|
|
<flux:subheading>
|
|
@if($currentStatus === 'published')
|
|
{{ __('Diese Pressemitteilung ist veröffentlicht. Sie wird nicht entfernt, sondern archiviert und der Inhalt wird durch den voreingestellten Ersatztext ersetzt, damit die URL keinen 404-Fehler erzeugt.') }}
|
|
@else
|
|
{{ __('Diese Pressemitteilung wird per Soft Delete aus den Standardlisten entfernt.') }}
|
|
@endif
|
|
</flux:subheading>
|
|
</div>
|
|
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
|
|
<flux:modal.close>
|
|
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
|
|
</flux:modal.close>
|
|
<flux:button variant="danger" wire:click="deletePressRelease">{{ __('Löschung bestätigen') }}</flux:button>
|
|
</div>
|
|
</div>
|
|
</flux:modal>
|
|
</div>
|