12-05-2026 Frontend dev
This commit is contained in:
parent
405df0a122
commit
5b8bdf4182
779 changed files with 480564 additions and 6241 deletions
290
resources/views/livewire/admin/press-releases/create.blade.php
Normal file
290
resources/views/livewire/admin/press-releases/create.blade.php
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
<?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 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,
|
||||
]);
|
||||
|
||||
$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' => $this->text,
|
||||
'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-6">
|
||||
<flux:card>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Neue Pressemitteilung') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Entwurf erstellen oder direkt zur Prüfung einreichen.') }}</flux:subheading>
|
||||
</div>
|
||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr,320px]">
|
||||
{{-- Hauptinhalt --}}
|
||||
<div class="space-y-6">
|
||||
<flux:card>
|
||||
<flux:heading size="md" class="mb-4">{{ __('Inhalt') }}</flux:heading>
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Titel') }} <span class="text-red-500">*</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-red-500">*</span></flux:label>
|
||||
<flux:textarea wire:model="text" rows="16" placeholder="{{ __('Vollständiger Text der Pressemitteilung…') }}" />
|
||||
<flux:error name="text" />
|
||||
</flux:field>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<flux:heading size="md" class="mb-4">{{ __('SEO & Links') }}</flux:heading>
|
||||
|
||||
<div class="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>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Sidebar --}}
|
||||
<div class="space-y-4">
|
||||
<flux:card>
|
||||
<flux:heading size="md" class="mb-4">{{ __('Metadaten') }}</flux:heading>
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Portal') }} <span class="text-red-500">*</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-red-500">*</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-red-500">*</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; @endphp
|
||||
<option value="{{ $cat->id }}">{{ $catName }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="categoryId" />
|
||||
</flux:field>
|
||||
|
||||
<flux:checkbox wire:model="noExport" label="{{ __('Kein Export') }}" />
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<div class="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>
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
480
resources/views/livewire/admin/press-releases/edit.blade.php
Normal file
480
resources/views/livewire/admin/press-releases/edit.blade.php
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
<?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\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;
|
||||
}
|
||||
|
||||
$pr->update([
|
||||
'portal' => $this->portal,
|
||||
'language' => $this->language,
|
||||
'company_id' => (int) $this->companyId,
|
||||
'category_id' => (int) $this->categoryId,
|
||||
'title' => $this->title,
|
||||
'slug' => $slug,
|
||||
'text' => $this->text,
|
||||
'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-6">
|
||||
@if(session('success'))
|
||||
<div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Pressemitteilung bearbeiten') }}</flux:heading>
|
||||
<flux:subheading>ID: {{ $id }}</flux:subheading>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:badge :color="$statusColor" size="lg">{{ $statusEnum?->label() ?? $currentStatus }}</flux:badge>
|
||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr,320px]">
|
||||
{{-- Hauptinhalt --}}
|
||||
<div class="space-y-6">
|
||||
<flux:card>
|
||||
<flux:heading size="md" class="mb-4">{{ __('Inhalt') }}</flux:heading>
|
||||
|
||||
<div class="space-y-4">
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Titel') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:input wire:model="title" />
|
||||
<flux:error name="title" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Text') }} <span class="text-red-500">*</span></flux:label>
|
||||
<flux:textarea wire:model="text" rows="20" />
|
||||
<flux:error name="text" />
|
||||
</flux:field>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<flux:card>
|
||||
<flux:heading size="md" class="mb-4">{{ __('SEO & Links') }}</flux:heading>
|
||||
|
||||
<div class="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>
|
||||
</flux:card>
|
||||
|
||||
<livewire:components.press-release-images-manager :press-release-id="$id" :wire:key="'pr-images-'.$id" />
|
||||
</div>
|
||||
|
||||
{{-- Sidebar --}}
|
||||
<div class="space-y-4">
|
||||
{{-- Status-Aktionen --}}
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-3">{{ __('Status-Aktionen') }}</flux:heading>
|
||||
|
||||
<div class="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>
|
||||
</flux:card>
|
||||
|
||||
{{-- Metadaten --}}
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-3">{{ __('Metadaten') }}</flux:heading>
|
||||
|
||||
<div class="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; @endphp
|
||||
<option value="{{ $cat->id }}">{{ $catName }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:error name="categoryId" />
|
||||
</flux:field>
|
||||
|
||||
<flux:checkbox wire:model="noExport" label="{{ __('Kein Export') }}" />
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
719
resources/views/livewire/admin/press-releases/index.blade.php
Normal file
719
resources/views/livewire/admin/press-releases/index.blade.php
Normal file
|
|
@ -0,0 +1,719 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\AdminPerformanceCache;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Pressemitteilungen')] class extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
#[Url(as: 'status', except: 'all')]
|
||||
public string $statusFilter = 'all';
|
||||
|
||||
public string $portalFilter = 'all';
|
||||
|
||||
public string $languageFilter = 'all';
|
||||
|
||||
#[Url(as: 'category', except: 'all')]
|
||||
public string $categoryFilter = 'all';
|
||||
|
||||
#[Url(as: 'user', except: 'all')]
|
||||
public string $userFilter = 'all';
|
||||
|
||||
#[Url(as: 'company', except: 'all')]
|
||||
public string $companyFilter = 'all';
|
||||
|
||||
#[Url(as: 'contact', except: 'all')]
|
||||
public string $contactFilter = 'all';
|
||||
|
||||
public string $userLookup = '';
|
||||
|
||||
public string $companyLookup = '';
|
||||
|
||||
public string $contactLookup = '';
|
||||
|
||||
public string $sortBy = 'created_at';
|
||||
|
||||
public string $sortDir = 'desc';
|
||||
|
||||
public function sort(string $column): void
|
||||
{
|
||||
if ($this->sortBy === $column) {
|
||||
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
$this->sortBy = $column;
|
||||
$this->sortDir = 'asc';
|
||||
}
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedStatusFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedPortalFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedLanguageFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedCategoryFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedUserFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedCompanyFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updatedContactFilter(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function clearUserFilter(): void
|
||||
{
|
||||
$this->userFilter = 'all';
|
||||
$this->userLookup = '';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function clearCompanyFilter(): void
|
||||
{
|
||||
$this->companyFilter = 'all';
|
||||
$this->companyLookup = '';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function clearContactFilter(): void
|
||||
{
|
||||
$this->contactFilter = 'all';
|
||||
$this->contactLookup = '';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function resetEntityFilters(): void
|
||||
{
|
||||
$this->clearUserFilter();
|
||||
$this->clearCompanyFilter();
|
||||
$this->clearContactFilter();
|
||||
}
|
||||
|
||||
public function publish(int $id): void
|
||||
{
|
||||
$pr = PressRelease::withoutGlobalScopes()->findOrFail($id);
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
} catch (BlacklistViolationException $e) {
|
||||
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
|
||||
|
||||
return;
|
||||
} catch (\LogicException $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', __('Pressemitteilung veröffentlicht.'));
|
||||
}
|
||||
|
||||
public function reject(int $id): void
|
||||
{
|
||||
$pr = PressRelease::withoutGlobalScopes()->findOrFail($id);
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->reject($pr, __('Bitte überarbeiten Sie die Pressemitteilung.'));
|
||||
} catch (\LogicException $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', __('Pressemitteilung abgelehnt.'));
|
||||
}
|
||||
|
||||
public function archive(int $id): void
|
||||
{
|
||||
$pr = PressRelease::withoutGlobalScopes()->findOrFail($id);
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->archive($pr);
|
||||
} catch (\LogicException $e) {
|
||||
session()->flash('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', __('Pressemitteilung archiviert.'));
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$query = PressRelease::withoutGlobalScopes()
|
||||
->with(['company:id,name', 'category.translations', 'user:id,name'])
|
||||
->when(filled($this->search), function ($q): void {
|
||||
$term = trim($this->search);
|
||||
$q->where(function ($q) use ($term): void {
|
||||
if ($this->supportsFullTextSearch($term)) {
|
||||
$q->whereFullText(['title', 'keywords'], $term)
|
||||
->orWhereHas('company', fn ($q) => $q->whereFullText(['name', 'email', 'slug'], $term));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$q->where('title', 'like', '%'.$term.'%')
|
||||
->orWhere('keywords', 'like', '%'.$term.'%')
|
||||
->orWhereHas('company', fn ($q) => $q->where('name', 'like', '%'.$term.'%'));
|
||||
});
|
||||
})
|
||||
->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter))
|
||||
->when($this->portalFilter !== 'all', fn ($q) => $q->where('portal', $this->portalFilter))
|
||||
->when($this->languageFilter !== 'all', fn ($q) => $q->where('language', $this->languageFilter))
|
||||
->when($this->categoryFilter !== 'all', fn ($q) => $q->where('category_id', (int) $this->categoryFilter))
|
||||
->when($this->userFilter !== 'all', fn ($q) => $q->where('user_id', (int) $this->userFilter))
|
||||
->when($this->companyFilter !== 'all', fn ($q) => $q->where('company_id', (int) $this->companyFilter))
|
||||
->when($this->contactFilter !== 'all', fn ($q) => $q->whereHas('contacts', fn ($contactQuery) => $contactQuery->where('contacts.id', (int) $this->contactFilter)))
|
||||
->orderBy(in_array($this->sortBy, ['title', 'status', 'portal', 'hits', 'created_at']) ? $this->sortBy : 'created_at', $this->sortDir)
|
||||
->simplePaginate(50);
|
||||
|
||||
return [
|
||||
'pressReleases' => $query,
|
||||
'stats' => $this->pressReleaseStats(),
|
||||
'statusOptions' => PressReleaseStatus::cases(),
|
||||
'portalOptions' => Portal::cases(),
|
||||
'categoryOptions' => $this->categoryOptions(),
|
||||
'userLookupResults' => $this->userLookupResults(),
|
||||
'companyLookupResults' => $this->companyLookupResults(),
|
||||
'contactLookupResults' => $this->contactLookupResults(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: int, published: int, review: int, draft: int}
|
||||
*/
|
||||
private function pressReleaseStats(): array
|
||||
{
|
||||
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::PressReleaseStats, AdminPerformanceCache::StatsTtl, function (): array {
|
||||
$stats = PressRelease::withoutGlobalScopes()
|
||||
->toBase()
|
||||
->selectRaw('COUNT(*) as total')
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as published', [PressReleaseStatus::Published->value])
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as review', [PressReleaseStatus::Review->value])
|
||||
->selectRaw('SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as draft', [PressReleaseStatus::Draft->value])
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total' => (int) ($stats->total ?? 0),
|
||||
'published' => (int) ($stats->published ?? 0),
|
||||
'review' => (int) ($stats->review ?? 0),
|
||||
'draft' => (int) ($stats->draft ?? 0),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function categoryOptions()
|
||||
{
|
||||
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::PressReleaseCategoryOptions, AdminPerformanceCache::OptionsTtl, fn () => Category::query()
|
||||
->select(['id', 'is_active'])
|
||||
->with(['translations:id,category_id,locale,name,slug'])
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->get());
|
||||
}
|
||||
|
||||
private function userLookupResults()
|
||||
{
|
||||
$term = trim($this->userLookup);
|
||||
|
||||
if ($term === '' && $this->userFilter === 'all') {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return User::query()
|
||||
->select(['id', 'name', 'email'])
|
||||
->where(function ($query) use ($term): void {
|
||||
if ($this->userFilter !== 'all') {
|
||||
$query->where('id', (int) $this->userFilter);
|
||||
}
|
||||
|
||||
if ($term !== '') {
|
||||
$query->orWhere(function ($searchQuery) use ($term): void {
|
||||
$searchQuery
|
||||
->where('name', 'like', '%'.$term.'%')
|
||||
->orWhere('email', 'like', '%'.$term.'%');
|
||||
});
|
||||
}
|
||||
})
|
||||
->orderBy('name')
|
||||
->limit(20)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function companyLookupResults()
|
||||
{
|
||||
$term = trim($this->companyLookup);
|
||||
|
||||
if ($term === '' && $this->companyFilter === 'all') {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Company::withoutGlobalScopes()
|
||||
->select(['id', 'name', 'slug', 'email'])
|
||||
->where(function ($query) use ($term): void {
|
||||
if ($this->companyFilter !== 'all') {
|
||||
$query->where('id', (int) $this->companyFilter);
|
||||
}
|
||||
|
||||
if ($term !== '') {
|
||||
$query->orWhere(function ($searchQuery) use ($term): void {
|
||||
$searchQuery
|
||||
->where('name', 'like', '%'.$term.'%')
|
||||
->orWhere('slug', 'like', '%'.$term.'%')
|
||||
->orWhere('email', 'like', '%'.$term.'%');
|
||||
});
|
||||
}
|
||||
})
|
||||
->orderBy('name')
|
||||
->limit(20)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function contactLookupResults()
|
||||
{
|
||||
$term = trim($this->contactLookup);
|
||||
|
||||
if ($term === '' && $this->contactFilter === 'all') {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Contact::withoutGlobalScopes()
|
||||
->select(['id', 'company_id', 'first_name', 'last_name', 'email'])
|
||||
->with('company:id,name')
|
||||
->where(function ($query) use ($term): void {
|
||||
if ($this->contactFilter !== 'all') {
|
||||
$query->where('id', (int) $this->contactFilter);
|
||||
}
|
||||
|
||||
if ($term !== '') {
|
||||
$query->orWhere(function ($searchQuery) use ($term): void {
|
||||
$searchQuery
|
||||
->where('first_name', 'like', '%'.$term.'%')
|
||||
->orWhere('last_name', 'like', '%'.$term.'%')
|
||||
->orWhere('email', 'like', '%'.$term.'%');
|
||||
});
|
||||
}
|
||||
})
|
||||
->orderBy('last_name')
|
||||
->orderBy('first_name')
|
||||
->limit(20)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function supportsFullTextSearch(string $term): bool
|
||||
{
|
||||
return mb_strlen($term) >= 3
|
||||
&& in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
@if (session('success'))
|
||||
<div
|
||||
class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Statistiken --}}
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<flux:card>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Gesamt') }}</flux:text>
|
||||
<flux:text size="xl" weight="bold">{{ $stats['total'] }}</flux:text>
|
||||
</flux:card>
|
||||
<flux:card>
|
||||
<flux:text class="text-xs text-green-600">{{ __('Veröffentlicht') }}</flux:text>
|
||||
<flux:text size="xl" weight="bold">{{ $stats['published'] }}</flux:text>
|
||||
</flux:card>
|
||||
<flux:card>
|
||||
<flux:text class="text-xs text-yellow-600">{{ __('In Prüfung') }}</flux:text>
|
||||
<flux:text size="xl" weight="bold">{{ $stats['review'] }}</flux:text>
|
||||
</flux:card>
|
||||
<flux:card>
|
||||
<flux:text class="text-xs text-zinc-500">{{ __('Entwürfe') }}</flux:text>
|
||||
<flux:text size="xl" weight="bold">{{ $stats['draft'] }}</flux:text>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<flux:button icon="plus" variant="primary" href="{{ route('admin.press-releases.create') }}" wire:navigate>
|
||||
{{ __('Neue PM') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Filter --}}
|
||||
<flux:card>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-6">
|
||||
<flux:input
|
||||
wire:model.live.debounce.300ms="search"
|
||||
placeholder="{{ __('Titel, Stichwort, Firma…') }}"
|
||||
icon="magnifying-glass"
|
||||
class="lg:col-span-2"
|
||||
/>
|
||||
|
||||
<flux:select wire:model.live="statusFilter" class="w-full">
|
||||
<option value="all">{{ __('Alle Status') }}</option>
|
||||
@foreach ($statusOptions as $s)
|
||||
<option value="{{ $s->value }}">{{ $s->label() }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="portalFilter" class="w-full">
|
||||
<option value="all">{{ __('Alle Portale') }}</option>
|
||||
@foreach ($portalOptions as $p)
|
||||
@if ($p !== \App\Enums\Portal::Both)
|
||||
<option value="{{ $p->value }}">{{ $p->label() }}</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="languageFilter" class="w-full">
|
||||
<option value="all">{{ __('Alle Sprachen') }}</option>
|
||||
<option value="de">DE</option>
|
||||
<option value="en">EN</option>
|
||||
</flux:select>
|
||||
|
||||
<flux:select wire:model.live="categoryFilter" class="w-full">
|
||||
<option value="all">{{ __('Alle Kategorien') }}</option>
|
||||
@foreach ($categoryOptions as $categoryOption)
|
||||
@php($categoryName = $categoryOption->translations->firstWhere('locale', 'de')?->name ?? '#' . $categoryOption->id)
|
||||
<option value="{{ $categoryOption->id }}">{{ $categoryName }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-3">
|
||||
<div class="flex gap-2">
|
||||
<flux:select
|
||||
wire:model.live="userFilter"
|
||||
variant="combobox"
|
||||
:filter="false"
|
||||
placeholder="{{ __('User suchen…') }}"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<x-slot name="input">
|
||||
<flux:select.input
|
||||
wire:model.live.debounce.300ms="userLookup"
|
||||
placeholder="{{ __('User suchen…') }}"
|
||||
/>
|
||||
</x-slot>
|
||||
|
||||
<flux:select.option value="all">{{ __('Alle User') }}</flux:select.option>
|
||||
@foreach($userLookupResults as $userOption)
|
||||
<flux:select.option :value="$userOption->id" wire:key="pm-user-{{ $userOption->id }}">
|
||||
{{ $userOption->name }}
|
||||
<span class="ml-1 text-zinc-400">· {{ $userOption->email }}</span>
|
||||
</flux:select.option>
|
||||
@endforeach
|
||||
|
||||
<x-slot name="empty">
|
||||
<flux:select.option.empty>
|
||||
{{ blank(trim($userLookup)) ? __('Zum Laden Usernamen oder E-Mail eingeben.') : __('Kein User gefunden.') }}
|
||||
</flux:select.option.empty>
|
||||
</x-slot>
|
||||
</flux:select>
|
||||
|
||||
<flux:button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="x-mark"
|
||||
wire:click="clearUserFilter"
|
||||
title="{{ __('Usersuche zurücksetzen') }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<flux:select
|
||||
wire:model.live="companyFilter"
|
||||
variant="combobox"
|
||||
:filter="false"
|
||||
placeholder="{{ __('Firma suchen…') }}"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<x-slot name="input">
|
||||
<flux:select.input
|
||||
wire:model.live.debounce.300ms="companyLookup"
|
||||
placeholder="{{ __('Firma, Slug oder E-Mail…') }}"
|
||||
/>
|
||||
</x-slot>
|
||||
|
||||
<flux:select.option value="all">{{ __('Alle Firmen') }}</flux:select.option>
|
||||
@foreach($companyLookupResults as $companyOption)
|
||||
<flux:select.option :value="$companyOption->id" wire:key="pm-company-{{ $companyOption->id }}">
|
||||
{{ $companyOption->name }}
|
||||
@if($companyOption->email)<span class="ml-1 text-zinc-400">· {{ $companyOption->email }}</span>@endif
|
||||
</flux:select.option>
|
||||
@endforeach
|
||||
|
||||
<x-slot name="empty">
|
||||
<flux:select.option.empty>
|
||||
{{ blank(trim($companyLookup)) ? __('Zum Laden Firmennamen, Slug oder E-Mail eingeben.') : __('Keine Firma gefunden.') }}
|
||||
</flux:select.option.empty>
|
||||
</x-slot>
|
||||
</flux:select>
|
||||
|
||||
<flux:button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="x-mark"
|
||||
wire:click="clearCompanyFilter"
|
||||
title="{{ __('Firmensuche zurücksetzen') }}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<flux:select
|
||||
wire:model.live="contactFilter"
|
||||
variant="combobox"
|
||||
:filter="false"
|
||||
placeholder="{{ __('Kontakt suchen…') }}"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<x-slot name="input">
|
||||
<flux:select.input
|
||||
wire:model.live.debounce.300ms="contactLookup"
|
||||
placeholder="{{ __('Kontakt oder E-Mail…') }}"
|
||||
/>
|
||||
</x-slot>
|
||||
|
||||
<flux:select.option value="all">{{ __('Alle Kontakte') }}</flux:select.option>
|
||||
@foreach($contactLookupResults as $contactOption)
|
||||
@php($contactName = trim(($contactOption->first_name ?? '').' '.($contactOption->last_name ?? '')) ?: __('Kontakt ohne Name'))
|
||||
<flux:select.option :value="$contactOption->id" wire:key="pm-contact-{{ $contactOption->id }}">
|
||||
{{ $contactName }}
|
||||
<span class="ml-1 text-zinc-400">
|
||||
@if($contactOption->email)· {{ $contactOption->email }} @endif
|
||||
· {{ $contactOption->company?->name ?? __('Unbekannte Firma') }}
|
||||
</span>
|
||||
</flux:select.option>
|
||||
@endforeach
|
||||
|
||||
<x-slot name="empty">
|
||||
<flux:select.option.empty>
|
||||
{{ blank(trim($contactLookup)) ? __('Zum Laden Kontaktname oder E-Mail eingeben.') : __('Kein Kontakt gefunden.') }}
|
||||
</flux:select.option.empty>
|
||||
</x-slot>
|
||||
</flux:select>
|
||||
|
||||
<flux:button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="x-mark"
|
||||
wire:click="clearContactFilter"
|
||||
title="{{ __('Kontaktsuche zurücksetzen') }}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Tabelle --}}
|
||||
<flux:card class="overflow-hidden">
|
||||
<flux:table>
|
||||
<flux:table.columns>
|
||||
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
|
||||
|
||||
<flux:table.column sortable :sorted="$sortBy === 'title'" :direction="$sortDir"
|
||||
wire:click="sort('title')">{{ __('Titel') }}</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'created_at'" :direction="$sortDir"
|
||||
wire:click="sort('created_at')">{{ __('Erstellt') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Kategorie') }}</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'status'" :direction="$sortDir"
|
||||
wire:click="sort('status')">{{ __('Status') }}</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'portal'" :direction="$sortDir"
|
||||
wire:click="sort('portal')">{{ __('Portal') }}</flux:table.column>
|
||||
<flux:table.column sortable :sorted="$sortBy === 'hits'" :direction="$sortDir"
|
||||
wire:click="sort('hits')">
|
||||
{{ __('Hits') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
|
||||
|
||||
</flux:table.columns>
|
||||
|
||||
@forelse($pressReleases as $pr)
|
||||
<flux:table.row wire:key="{{ $pr->id }}">
|
||||
<flux:table.cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="eye"
|
||||
href="{{ route('admin.press-releases.show', $pr->id) }}" wire:navigate />
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate />
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<div class="max-w-xs">
|
||||
<p class="truncate font-medium">{{ $pr->title ?? '–' }}</p>
|
||||
<p class="text-sm truncate text-zinc-400">
|
||||
{{ $pr->company?->name ?? '–' . ' | ' . strtoupper($pr->language) }}
|
||||
</p>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:text class="text-sm text-zinc-500">{{ $pr->created_at->format('d.m.Y H:i') }}</flux:text>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
@php($categoryName = $pr->category?->translations->firstWhere('locale', 'de')?->name ?? '–')
|
||||
<div class="max-w-48">
|
||||
<flux:text class="truncate text-sm" title="{{ $categoryName }}">{{ $categoryName }}
|
||||
</flux:text>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:badge
|
||||
color="{{ match ($pr->status->value) {
|
||||
'published' => 'green',
|
||||
'review' => 'yellow',
|
||||
'draft' => 'zinc',
|
||||
'rejected' => 'red',
|
||||
'archived' => 'blue',
|
||||
} }}">
|
||||
{{ $pr->status->label() }}
|
||||
</flux:badge>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:text class="text-sm">{{ $pr->portal->label() }}</flux:text>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:text class="text-sm">{{ number_format($pr->hits) }}</flux:text>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<div class="flex items-center gap-1">
|
||||
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
|
||||
<flux:modal.trigger name="confirm-index-publish-{{ $pr->id }}">
|
||||
<flux:button size="sm" variant="ghost" icon="check-circle"
|
||||
class="text-green-600" />
|
||||
</flux:modal.trigger>
|
||||
<flux:modal.trigger name="confirm-index-reject-{{ $pr->id }}">
|
||||
<flux:button size="sm" variant="ghost" icon="x-circle" class="text-red-600" />
|
||||
</flux:modal.trigger>
|
||||
@endif
|
||||
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
|
||||
<flux:modal.trigger name="confirm-index-archive-{{ $pr->id }}">
|
||||
<flux:button size="sm" variant="ghost" icon="archive-box"
|
||||
class="text-zinc-500" />
|
||||
</flux:modal.trigger>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($pr->status === \App\Enums\PressReleaseStatus::Review)
|
||||
<flux:modal name="confirm-index-publish-{{ $pr->id }}" class="max-w-lg">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Pressemitteilung veröffentlichen?') }}
|
||||
</flux:heading>
|
||||
<flux:subheading>
|
||||
{{ __('Diese Aktion veröffentlicht die Pressemitteilung ":title".', ['title' => \Illuminate\Support\Str::limit($pr->title, 80)]) }}
|
||||
</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="publish({{ $pr->id }})">
|
||||
{{ __('Veröffentlichen') }}</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
<flux:modal name="confirm-index-reject-{{ $pr->id }}" class="max-w-lg">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Pressemitteilung ablehnen?') }}
|
||||
</flux:heading>
|
||||
<flux:subheading>
|
||||
{{ __('Diese Aktion lehnt die Pressemitteilung ":title" ab.', ['title' => \Illuminate\Support\Str::limit($pr->title, 80)]) }}
|
||||
</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="reject({{ $pr->id }})">
|
||||
{{ __('Ablehnen') }}</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
@endif
|
||||
|
||||
@if ($pr->status === \App\Enums\PressReleaseStatus::Published)
|
||||
<flux:modal name="confirm-index-archive-{{ $pr->id }}" class="max-w-lg">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Pressemitteilung archivieren?') }}
|
||||
</flux:heading>
|
||||
<flux:subheading>
|
||||
{{ __('Diese Aktion archiviert die Pressemitteilung ":title".', ['title' => \Illuminate\Support\Str::limit($pr->title, 80)]) }}
|
||||
</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="archive({{ $pr->id }})">
|
||||
{{ __('Archivieren') }}</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="8">
|
||||
<div class="flex flex-col items-center justify-center py-10">
|
||||
<flux:icon.newspaper class="size-10 text-zinc-300" />
|
||||
<flux:text class="mt-3 text-zinc-500">{{ __('Keine Pressemitteilungen gefunden.') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
</flux:table.cell>
|
||||
</flux:table.row>
|
||||
@endforelse
|
||||
</flux:table>
|
||||
</flux:card>
|
||||
|
||||
{{ $pressReleases->links() }}
|
||||
</div>
|
||||
331
resources/views/livewire/admin/press-releases/show.blade.php
Normal file
331
resources/views/livewire/admin/press-releases/show.blade.php
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
<?php
|
||||
|
||||
use App\Models\PressRelease;
|
||||
use App\Services\PressRelease\BlacklistViolationException;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Flux\Flux;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public int $id;
|
||||
|
||||
public string $rejectReason = '';
|
||||
|
||||
public function mount(int $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
public function publish(): void
|
||||
{
|
||||
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
|
||||
|
||||
try {
|
||||
app(PressReleaseService::class)->publish($pr);
|
||||
} catch (BlacklistViolationException $e) {
|
||||
session()->flash('error', __('Automatisch abgelehnt: unzulässiges Wort ":word".', ['word' => $e->word]));
|
||||
Flux::modal('confirm-show-publish')->close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
session()->flash('success', __('Pressemitteilung veröffentlicht. Autor wurde benachrichtigt.'));
|
||||
Flux::modal('confirm-show-publish')->close();
|
||||
}
|
||||
|
||||
public function reject(): void
|
||||
{
|
||||
$this->validate([
|
||||
'rejectReason' => ['required', 'string', 'min:5', 'max:2000'],
|
||||
]);
|
||||
|
||||
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
|
||||
app(PressReleaseService::class)->reject($pr, trim($this->rejectReason));
|
||||
|
||||
$this->rejectReason = '';
|
||||
|
||||
session()->flash('success', __('Pressemitteilung abgelehnt. Autor wurde benachrichtigt.'));
|
||||
Flux::modal('confirm-show-reject')->close();
|
||||
}
|
||||
|
||||
public function archive(): void
|
||||
{
|
||||
$pr = PressRelease::withoutGlobalScopes()->findOrFail($this->id);
|
||||
app(PressReleaseService::class)->archive($pr);
|
||||
session()->flash('success', __('Archiviert.'));
|
||||
Flux::modal('confirm-show-archive')->close();
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
$pr = PressRelease::withoutGlobalScopes()
|
||||
->with([
|
||||
'company:id,name,slug',
|
||||
'category.translations',
|
||||
'user:id,name',
|
||||
'images',
|
||||
'statusLogs.changedBy:id,name',
|
||||
])
|
||||
->findOrFail($this->id);
|
||||
|
||||
return [
|
||||
'pr' => $pr,
|
||||
'statusLogs' => $pr->statusLogs,
|
||||
'categoryName' => $pr->category?->translations->firstWhere('locale', 'de')?->name
|
||||
?? $pr->category?->translations->first()?->name
|
||||
?? '–',
|
||||
'statusColor' => match ($pr->status->value) {
|
||||
'published' => 'green',
|
||||
'review' => 'yellow',
|
||||
'rejected' => 'red',
|
||||
'archived' => 'blue',
|
||||
default => 'zinc',
|
||||
},
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-6">
|
||||
@if(session('success'))
|
||||
<div class="rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:badge :color="$statusColor">{{ $pr->status->label() }}</flux:badge>
|
||||
<flux:badge color="zinc" size="sm">{{ strtoupper($pr->language) }}</flux:badge>
|
||||
<flux:badge color="zinc" size="sm">{{ $pr->portal->label() }}</flux:badge>
|
||||
</div>
|
||||
<flux:heading size="xl" class="mt-2">{{ $pr->title }}</flux:heading>
|
||||
<flux:text class="mt-1 text-sm text-zinc-500">
|
||||
{{ __('Firma') }}: {{ $pr->company?->name ?? '–' }} ·
|
||||
{{ __('Kategorie') }}: {{ $categoryName }} ·
|
||||
{{ __('Autor') }}: {{ $pr->user?->name ?? '–' }}
|
||||
</flux:text>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<flux:button variant="ghost" icon="pencil" href="{{ route('admin.press-releases.edit', $pr->id) }}" wire:navigate>
|
||||
{{ __('Bearbeiten') }}
|
||||
</flux:button>
|
||||
<flux:button variant="ghost" icon="arrow-left" href="{{ route('admin.press-releases.index') }}" wire:navigate>
|
||||
{{ __('Zurück') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Status-Aktionen --}}
|
||||
@if($pr->status === \App\Enums\PressReleaseStatus::Review)
|
||||
<flux:card>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<flux:text weight="medium" class="text-yellow-700 dark:text-yellow-400">
|
||||
{{ __('Diese PM wartet auf Prüfung.') }}
|
||||
</flux:text>
|
||||
<flux:modal.trigger name="confirm-show-publish">
|
||||
<flux:button type="button" variant="primary">{{ __('Veröffentlichen') }}</flux:button>
|
||||
</flux:modal.trigger>
|
||||
<flux:modal.trigger name="confirm-show-reject">
|
||||
<flux:button type="button" variant="danger">{{ __('Ablehnen') }}</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
@if($pr->status === \App\Enums\PressReleaseStatus::Published)
|
||||
<flux:card>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:modal.trigger name="confirm-show-archive">
|
||||
<flux:button type="button" variant="ghost">{{ __('Archivieren') }}</flux:button>
|
||||
</flux:modal.trigger>
|
||||
@if($pr->hits > 0)
|
||||
<flux:text class="text-sm text-zinc-500">{{ number_format($pr->hits) }} {{ __('Aufrufe') }}</flux:text>
|
||||
@endif
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr,280px]">
|
||||
{{-- Text --}}
|
||||
<flux:card>
|
||||
<div class="prose prose-zinc dark:prose-invert max-w-none">
|
||||
{!! nl2br(e($pr->text)) !!}
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Details --}}
|
||||
<div class="space-y-4">
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-3">{{ __('Details') }}</flux:heading>
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-zinc-500">{{ __('Status') }}</dt>
|
||||
<dd class="font-medium">{{ $pr->status->label() }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-zinc-500">{{ __('Erstellt') }}</dt>
|
||||
<dd>{{ $pr->created_at->format('d.m.Y H:i') }}</dd>
|
||||
</div>
|
||||
@if($pr->published_at)
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-zinc-500">{{ __('Veröffentlicht') }}</dt>
|
||||
<dd>{{ $pr->published_at->format('d.m.Y H:i') }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-zinc-500">{{ __('Aufrufe') }}</dt>
|
||||
<dd>{{ number_format($pr->hits) }}</dd>
|
||||
</div>
|
||||
@if($pr->keywords)
|
||||
<div>
|
||||
<dt class="text-zinc-500">{{ __('Stichwörter') }}</dt>
|
||||
<dd class="mt-1">{{ $pr->keywords }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if($pr->backlink_url)
|
||||
<div>
|
||||
<dt class="text-zinc-500">{{ __('Backlink') }}</dt>
|
||||
<dd class="mt-1 break-all">
|
||||
<a href="{{ $pr->backlink_url }}" target="_blank" class="text-blue-600 underline dark:text-blue-400">
|
||||
{{ $pr->backlink_url }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
@if($pr->no_export)
|
||||
<div class="rounded bg-zinc-100 px-2 py-1 text-xs text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
|
||||
{{ __('Kein Export') }}
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
</flux:card>
|
||||
|
||||
@if($pr->images->isNotEmpty())
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-3">{{ __('Bilder') }}</flux:heading>
|
||||
<div class="space-y-2">
|
||||
@foreach($pr->images as $image)
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<flux:icon.photo class="size-4 text-zinc-400" />
|
||||
<span class="truncate text-zinc-600 dark:text-zinc-400">{{ basename($image->path) }}</span>
|
||||
@if($image->is_preview)
|
||||
<flux:badge size="sm" color="blue">{{ __('Preview') }}</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($statusLogs->isNotEmpty())
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-3">{{ __('Status-Verlauf') }}</flux:heading>
|
||||
<ol class="space-y-3 border-s border-zinc-200 ps-4 dark:border-zinc-700">
|
||||
@foreach($statusLogs as $log)
|
||||
<li class="text-sm">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@php
|
||||
$color = match($log->to_status?->value) {
|
||||
'published' => 'green',
|
||||
'review' => 'yellow',
|
||||
'rejected' => 'red',
|
||||
'archived' => 'blue',
|
||||
default => 'zinc',
|
||||
};
|
||||
@endphp
|
||||
<flux:badge size="sm" :color="$color">{{ $log->to_status?->label() ?? $log->to_status }}</flux:badge>
|
||||
@if($log->from_status)
|
||||
<span class="text-xs text-zinc-500">
|
||||
{{ __('von') }} {{ $log->from_status->label() }}
|
||||
</span>
|
||||
@endif
|
||||
<span class="text-xs text-zinc-500">·</span>
|
||||
<span class="text-xs text-zinc-500">
|
||||
{{ $log->created_at->format('d.m.Y H:i') }}
|
||||
</span>
|
||||
@if($log->changedBy)
|
||||
<span class="text-xs text-zinc-500">·</span>
|
||||
<span class="text-xs text-zinc-500">{{ $log->changedBy->name }}</span>
|
||||
@endif
|
||||
@if($log->source !== 'admin')
|
||||
<flux:badge size="xs" color="zinc">{{ $log->source }}</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
@if($log->reason)
|
||||
<p class="mt-1 text-zinc-600 dark:text-zinc-400">{{ $log->reason }}</p>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
@if($pr->status === \App\Enums\PressReleaseStatus::Review)
|
||||
<flux:modal name="confirm-show-publish" class="max-w-lg">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Pressemitteilung veröffentlichen?') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Die Pressemitteilung wird öffentlich sichtbar und der Autor wird benachrichtigt.') }}</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="publish">{{ __('Veröffentlichen') }}</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
|
||||
<flux:modal name="confirm-show-reject" class="max-w-lg">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Pressemitteilung ablehnen?') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Die Pressemitteilung wird abgelehnt und der Autor wird benachrichtigt. Bitte begründen Sie die Ablehnung.') }}</flux:subheading>
|
||||
</div>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Begründung (an den Autor sichtbar)') }}</flux:label>
|
||||
<flux:textarea
|
||||
wire:model="rejectReason"
|
||||
rows="5"
|
||||
placeholder="{{ __('z. B. Werbliche Sprache, fehlende Belege, doppelte Veröffentlichung…') }}"
|
||||
/>
|
||||
<flux:error name="rejectReason" />
|
||||
</flux:field>
|
||||
|
||||
<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="reject">{{ __('Ablehnen') }}</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
@endif
|
||||
|
||||
@if($pr->status === \App\Enums\PressReleaseStatus::Published)
|
||||
<flux:modal name="confirm-show-archive" class="max-w-lg">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ __('Pressemitteilung archivieren?') }}</flux:heading>
|
||||
<flux:subheading>{{ __('Die Pressemitteilung bleibt intern erhalten, wird aber archiviert.') }}</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="archive">{{ __('Archivieren') }}</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
@endif
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue