10-04-2026
This commit is contained in:
parent
4d6b4930b2
commit
4bb89aad8c
836 changed files with 52961 additions and 5950 deletions
367
resources/views/livewire/admin/cms/articles-index.blade.php
Normal file
367
resources/views/livewire/admin/cms/articles-index.blade.php
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
<?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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue