313 lines
13 KiB
PHP
313 lines
13 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\PressReleaseHtmlSanitizer;
|
|
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\Title;
|
|
use Livewire\Volt\Component;
|
|
|
|
new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class extends Component
|
|
{
|
|
public string $portal = 'presseecho';
|
|
|
|
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 function updatedCompanySearch(): void
|
|
{
|
|
$this->resetErrorBag('companyId');
|
|
}
|
|
|
|
public function updatedTitle(): void
|
|
{
|
|
$this->resetErrorBag('title');
|
|
}
|
|
|
|
public function save(string $submitStatus = 'draft'): 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'],
|
|
]);
|
|
|
|
$status = match ($submitStatus) {
|
|
'review' => PressReleaseStatus::Review,
|
|
default => PressReleaseStatus::Draft,
|
|
};
|
|
|
|
$slug = (new PressRelease)->generateUniqueSlug($this->title, [
|
|
'portal' => $this->portal,
|
|
'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' => auth()->id(),
|
|
'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,
|
|
'status' => $status->value,
|
|
'no_export' => $this->noExport,
|
|
]);
|
|
|
|
session()->flash('success', $status === PressReleaseStatus::Review
|
|
? __('Pressemitteilung zur Prüfung eingereicht.')
|
|
: __('Pressemitteilung als Entwurf gespeichert.'));
|
|
|
|
$this->redirect(route('admin.press-releases.edit', $pr->id), 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']);
|
|
|
|
return [
|
|
'companies' => $companies,
|
|
'categories' => $this->categoryOptions(),
|
|
'portalOptions' => array_filter(Portal::cases(), fn (Portal $p) => $p !== Portal::Both),
|
|
];
|
|
}
|
|
|
|
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">
|
|
{{-- ============== 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-nowrap whitespace-nowrap">
|
|
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
|
|
<span class="eyebrow muted">{{ __('Content · Neu anlegen') }}</span>
|
|
</div>
|
|
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
|
|
{{ __('Neue Pressemitteilung') }}
|
|
</h1>
|
|
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
|
|
{{ __('Entwurf erstellen oder direkt zur Prüfung einreichen.') }}
|
|
</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.live.debounce.500ms="title" placeholder="{{ __('Aussagekräftiger Titel…') }}" />
|
|
<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"
|
|
placeholder="{{ __('Vollständiger Text der Pressemitteilung…') }}"
|
|
/>
|
|
<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="{{ __('Kommagetrennte Stichwörter…') }}" />
|
|
<flux:error name="keywords" />
|
|
</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>
|
|
</div>
|
|
|
|
{{-- ============== SIDEBAR ============== --}}
|
|
<aside class="space-y-4">
|
|
<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') }} <span class="text-[color:var(--color-err)]">*</span></flux:label>
|
|
<flux:select wire:model="portal">
|
|
@foreach ($portalOptions as $p)
|
|
<option value="{{ $p->value }}">{{ $p->label() }}</option>
|
|
@endforeach
|
|
</flux:select>
|
|
<flux:error name="portal" />
|
|
</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') }} <span class="text-[color:var(--color-err)]">*</span></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') }} <span class="text-[color:var(--color-err)]">*</span></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('review')"
|
|
wire:loading.attr="disabled"
|
|
>
|
|
{{ __('Zur Prüfung einreichen') }}
|
|
</flux:button>
|
|
<flux:button
|
|
type="button"
|
|
variant="ghost"
|
|
class="w-full"
|
|
wire:click="save('draft')"
|
|
wire:loading.attr="disabled"
|
|
>
|
|
{{ __('Als Entwurf speichern') }}
|
|
</flux:button>
|
|
</div>
|
|
</article>
|
|
</aside>
|
|
</div>
|
|
</div>
|