b2in/resources/views/livewire/admin/cms/articles-index.blade.php
2026-04-10 17:18:17 +02:00

367 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Models\CmsArticle;
use Flux\Flux;
use function Livewire\Volt\{layout, title, state, computed, on};
layout('components.layouts.app');
title('Magazin verwalten');
state([
'search' => '',
'showForm' => false,
'editingId' => null,
'editLocale' => 'de',
'slug' => '',
'articleTitle' => '',
'subtitle' => '',
'image' => '',
'category' => '',
'date_label' => '',
'read_time' => '',
'authorName' => '',
'authorBio' => '',
'authorAvatar' => '',
'intro' => '',
'sections' => [],
'is_published' => true,
'order' => 0,
]);
on(['media-selected' => function ($mediaId, $url, $field) {
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
if ($field === 'article_image' && $media) {
$this->image = $media->filename;
}
if ($field === 'author_avatar' && $media) {
$this->authorAvatar = $media->filename;
}
}]);
$articles = computed(
fn () => CmsArticle::query()
->when($this->search, fn ($q, $s) => $q->where('slug', 'like', "%{$s}%")
->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(title, '$.de')) LIKE ?", ["%{$s}%"]))
->ordered()
->get(),
);
$openCreate = function () {
$this->editingId = null;
$this->reset(['slug', 'articleTitle', 'subtitle', 'image', 'category', 'date_label', 'read_time', 'authorName', 'authorBio', 'authorAvatar', 'intro', 'sections']);
$this->is_published = true;
$this->order = 0;
$this->sections = [['title' => '', 'content' => '']];
$this->showForm = true;
};
$openEdit = function (int $id) {
$article = CmsArticle::find($id);
if (! $article) {
return;
}
$this->editingId = $id;
$this->slug = $article->slug;
$this->image = $article->image ?? '';
$this->category = $article->category ?? '';
$this->date_label = $article->date_label ?? '';
$this->read_time = $article->read_time ?? '';
$this->is_published = $article->is_published;
$this->order = $article->order ?? 0;
$author = $article->author ?? [];
$this->authorName = $author['name'] ?? '';
$this->authorBio = $author['bio'] ?? '';
$this->authorAvatar = $author['avatar'] ?? '';
$this->loadLocaleFields($article, $this->editLocale);
$this->showForm = true;
};
$loadLocaleFields = function (CmsArticle $article, string $locale) {
$this->articleTitle = $article->getTranslation('title', $locale) ?? '';
$this->subtitle = $article->getTranslation('subtitle', $locale) ?? '';
$content = $article->getTranslation('content', $locale);
$this->intro = $content['intro'] ?? '';
$this->sections = $content['sections'] ?? [['title' => '', 'content' => '']];
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId) {
$article = CmsArticle::find($this->editingId);
if ($article) {
$this->loadLocaleFields($article, $locale);
}
}
};
$addSection = function () {
$this->sections[] = ['title' => '', 'content' => ''];
};
$removeSection = function (int $index) {
unset($this->sections[$index]);
$this->sections = array_values($this->sections);
};
$save = function () {
$validated = validator([
'slug' => $this->slug,
'articleTitle' => $this->articleTitle,
'subtitle' => $this->subtitle,
'image' => $this->image,
'category' => $this->category,
'date_label' => $this->date_label,
'read_time' => $this->read_time,
'authorName' => $this->authorName,
'intro' => $this->intro,
'is_published' => $this->is_published,
'order' => $this->order,
], [
'slug' => 'required|string|max:255',
'articleTitle' => 'required|string|max:500',
'subtitle' => 'nullable|string|max:1000',
'image' => 'nullable|string|max:500',
'category' => 'nullable|string|max:255',
'date_label' => 'nullable|string|max:100',
'read_time' => 'nullable|string|max:50',
'authorName' => 'nullable|string|max:255',
'intro' => 'nullable|string',
'is_published' => 'boolean',
'order' => 'integer|min:0',
])->validate();
$article = $this->editingId
? CmsArticle::findOrFail($this->editingId)
: CmsArticle::query()->make();
$article->slug = $validated['slug'];
$article->setTranslation('title', $this->editLocale, $validated['articleTitle']);
$article->setTranslation('subtitle', $this->editLocale, $validated['subtitle'] ?? '');
$contentData = [
'intro' => $validated['intro'] ?? '',
'sections' => collect($this->sections)
->filter(fn ($s) => ! empty($s['title']) || ! empty($s['content']))
->values()
->toArray(),
];
$article->setTranslation('content', $this->editLocale, $contentData);
$article->image = $validated['image'] ?? null;
$article->category = $validated['category'] ?? null;
$article->date_label = $validated['date_label'] ?? null;
$article->read_time = $validated['read_time'] ?? null;
$article->author = [
'name' => $this->authorName,
'bio' => $this->authorBio,
'avatar' => $this->authorAvatar,
];
$article->is_published = $validated['is_published'];
$article->order = $validated['order'];
$article->save();
$this->showForm = false;
$this->editingId = null;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Artikel wurde erfolgreich gespeichert.');
};
$togglePublished = function (int $id) {
$article = CmsArticle::findOrFail($id);
$article->update(['is_published' => ! $article->is_published]);
Flux::toast(heading: 'Status geändert', text: $article->is_published ? 'Veröffentlicht' : 'Entwurf');
};
$deleteArticle = function (int $id) {
$article = CmsArticle::findOrFail($id);
$article->delete();
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Artikel wurde entfernt.');
};
$cancelForm = function () {
$this->showForm = false;
$this->editingId = null;
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<flux:heading size="xl">Magazin Beiträge</flux:heading>
<div class="flex items-center gap-2">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
<flux:button variant="primary" icon="plus" wire:click="openCreate">Neuer Artikel</flux:button>
</div>
</div>
<div class="mb-4">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Artikel suchen..." icon="magnifying-glass"
size="sm" class="w-64" />
</div>
@if ($showForm)
<flux:card class="mb-6">
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ $editingId ? 'Artikel bearbeiten' : 'Neuer Artikel' }}</flux:heading>
<div class="flex gap-1">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<flux:input wire:model="slug" label="Slug" placeholder="escrow-system-dubai-investoren" />
<flux:input wire:model="articleTitle" label="Titel ({{ strtoupper($editLocale) }})" placeholder="Artikeltitel" />
<div class="md:col-span-2">
<flux:input wire:model="subtitle" label="Untertitel ({{ strtoupper($editLocale) }})" placeholder="Kurze Beschreibung" />
</div>
<flux:input wire:model="category" label="Kategorie" placeholder="z.B. Dubai Investment" />
<flux:input wire:model="date_label" label="Datum (Anzeige)" placeholder="März 10, 2026" />
<flux:input wire:model="read_time" label="Lesezeit" placeholder="6 min read" />
<flux:input wire:model="order" label="Sortierung" type="number" />
</div>
{{-- Bild --}}
<div class="mt-4">
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Artikelbild</label>
<div class="flex items-start gap-3">
@if ($image)
<div class="h-16 w-24 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<img src="{{ media_url($image) }}" class="h-full w-full object-cover" />
</div>
@endif
<div class="flex-1">
<livewire:admin.cms.media-picker
:value="null"
field="article_image"
type="image"
profile="card"
label="Bild wählen"
:key="'article-img-' . ($editingId ?? 'new')" />
<p class="mt-1 text-xs text-zinc-400">{{ $image ?: 'Kein Bild' }}</p>
</div>
</div>
</div>
{{-- Autor --}}
<div class="mt-6">
<flux:heading size="sm" class="mb-3">Autor</flux:heading>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<flux:input wire:model="authorName" label="Name" placeholder="Marcel Scheibe" />
<flux:input wire:model="authorBio" label="Bio" placeholder="Kurze Biografie" />
<div>
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Avatar</label>
<div class="flex items-center gap-2">
@if ($authorAvatar)
<div class="h-10 w-10 shrink-0 overflow-hidden rounded-full border border-zinc-200 dark:border-zinc-700">
<img src="{{ media_url($authorAvatar) }}" class="h-full w-full object-cover" />
</div>
@endif
<livewire:admin.cms.media-picker
:value="null"
field="author_avatar"
type="image"
label="Avatar wählen"
:key="'author-avatar-' . ($editingId ?? 'new')" />
</div>
</div>
</div>
</div>
{{-- Inhalt --}}
<div class="mt-6">
<flux:heading size="sm" class="mb-3">Inhalt ({{ strtoupper($editLocale) }})</flux:heading>
<flux:textarea wire:model="intro" label="Einleitung" rows="4" placeholder="Einleitungstext des Artikels..." />
<div class="mt-4">
<div class="mb-2 flex items-center justify-between">
<label class="text-sm font-medium text-zinc-700 dark:text-zinc-300">Abschnitte</label>
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addSection">Abschnitt hinzufügen</flux:button>
</div>
<div class="space-y-4">
@foreach ($sections as $i => $section)
<div wire:key="section-{{ $i }}" class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-zinc-500">Abschnitt {{ $i + 1 }}</span>
@if (count($sections) > 1)
<flux:button size="xs" variant="ghost" icon="trash" wire:click="removeSection({{ $i }})" />
@endif
</div>
<flux:input wire:model="sections.{{ $i }}.title" label="Überschrift" placeholder="Abschnitt-Titel" class="mb-3" />
<flux:textarea wire:model="sections.{{ $i }}.content" label="Text" rows="3" placeholder="Abschnitt-Inhalt..." />
</div>
@endforeach
</div>
</div>
</div>
<div class="mt-4">
<flux:switch wire:model="is_published" label="Veröffentlicht" />
</div>
<div class="mt-4 flex gap-2">
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
<flux:button variant="ghost" wire:click="cancelForm">Abbrechen</flux:button>
</div>
</flux:card>
@endif
<flux:card>
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->articles as $article)
<div wire:key="article-{{ $article->id }}" class="flex items-center justify-between gap-4 py-3">
<div class="flex min-w-0 flex-1 items-center gap-3">
@if ($article->image)
<div class="h-12 w-20 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
<img src="{{ media_url($article->image) }}" alt="" class="h-full w-full object-cover" loading="lazy" />
</div>
@else
<div class="flex h-12 w-20 shrink-0 items-center justify-center rounded-lg bg-zinc-100 dark:bg-zinc-700">
<x-heroicon-o-newspaper class="h-6 w-6 text-zinc-400" />
</div>
@endif
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-zinc-800 dark:text-zinc-200">
{{ strip_tags($article->getTranslation('title', $editLocale)) }}
</span>
@if ($article->category)
<flux:badge size="sm" color="blue">{{ $article->category }}</flux:badge>
@endif
@unless ($article->is_published)
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
@endunless
</div>
<p class="text-sm text-zinc-500">
{{ $article->author['name'] ?? '' }} · {{ $article->date_label ?? '' }} · {{ $article->read_time ?? '' }}
</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="openEdit({{ $article->id }})" />
<flux:button size="sm" variant="ghost" :icon="$article->is_published ? 'eye' : 'eye-slash'"
wire:click="togglePublished({{ $article->id }})" />
<flux:button size="sm" variant="ghost" icon="trash"
wire:click="deleteArticle({{ $article->id }})"
wire:confirm="Artikel wirklich löschen?" />
</div>
</div>
@empty
<div class="py-12 text-center">
<x-heroicon-o-newspaper class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
<flux:heading>Keine Artikel</flux:heading>
<flux:text>Erstelle den ersten Magazin-Beitrag mit dem Button oben.</flux:text>
</div>
@endforelse
</div>
</flux:card>
</div>