10-04-2026
This commit is contained in:
parent
4d6b4930b2
commit
4bb89aad8c
836 changed files with 52961 additions and 5950 deletions
|
|
@ -0,0 +1,473 @@
|
|||
<?php
|
||||
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use FluxCms\Core\Services\HeroiconOutlineList;
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use function Livewire\Volt\{state, computed, on};
|
||||
|
||||
state([
|
||||
'selectedGroup' => null,
|
||||
'search' => '',
|
||||
'editingId' => null,
|
||||
'editLocale' => 'de',
|
||||
'editValue' => '',
|
||||
'editMediaId' => null,
|
||||
'showJsonModal' => false,
|
||||
'jsonItems' => [],
|
||||
'jsonIsStringArray' => false,
|
||||
'jsonEditingKey' => '',
|
||||
]);
|
||||
|
||||
on(['media-selected' => function ($mediaId, $url, $field) {
|
||||
if ($field !== 'content_image') {
|
||||
return;
|
||||
}
|
||||
$media = CmsMedia::find($mediaId);
|
||||
if ($media) {
|
||||
$this->editValue = $media->filename;
|
||||
$this->editMediaId = $mediaId;
|
||||
}
|
||||
}]);
|
||||
|
||||
$groups = computed(fn() => CmsContent::query()->selectRaw('`group`, count(*) as count')->groupBy('group')->orderBy('group')->pluck('count', 'group')->toArray());
|
||||
|
||||
$contents = computed(fn() => $this->selectedGroup ? CmsContent::forGroup($this->selectedGroup)->when($this->search, fn($q) => $q->where('key', 'like', "%{$this->search}%"))->orderBy('order')->get() : collect());
|
||||
|
||||
$availableIcons = computed(fn () => HeroiconOutlineList::names());
|
||||
|
||||
$selectGroup = function (string $group) {
|
||||
$this->selectedGroup = $group;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
$startEdit = function (int $id) {
|
||||
$content = CmsContent::find($id);
|
||||
if (!$content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editingId = $id;
|
||||
|
||||
if ($content->type === 'json') {
|
||||
$value = $content->getTranslation('value', $this->editLocale);
|
||||
if (!is_array($value)) {
|
||||
$value = [];
|
||||
}
|
||||
|
||||
$this->jsonEditingKey = $content->key;
|
||||
|
||||
if (!empty($value) && !is_array($value[0] ?? null)) {
|
||||
$this->jsonIsStringArray = true;
|
||||
$this->jsonItems = array_map(fn($v) => ['_value' => (string) $v], $value);
|
||||
} else {
|
||||
$this->jsonIsStringArray = false;
|
||||
$this->jsonItems = array_map(fn($item) => is_array($item) ? array_map(fn($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v, $item) : ['_value' => (string) $item], $value);
|
||||
}
|
||||
|
||||
$this->showJsonModal = true;
|
||||
} else {
|
||||
$this->editValue = $content->getTranslation('value', $this->editLocale) ?? '';
|
||||
if (is_array($this->editValue)) {
|
||||
$this->editValue = json_encode($this->editValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
if ($content->type === 'image') {
|
||||
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
|
||||
} else {
|
||||
$this->editMediaId = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$content = CmsContent::find($this->editingId);
|
||||
if (!$content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($content->type === 'json') {
|
||||
$value = $content->getTranslation('value', $locale);
|
||||
if (!is_array($value)) {
|
||||
$value = [];
|
||||
}
|
||||
|
||||
if (!empty($value) && !is_array($value[0] ?? null)) {
|
||||
$this->jsonIsStringArray = true;
|
||||
$this->jsonItems = array_map(fn($v) => ['_value' => (string) $v], $value);
|
||||
} else {
|
||||
$this->jsonIsStringArray = false;
|
||||
$this->jsonItems = array_map(fn($item) => is_array($item) ? array_map(fn($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v, $item) : ['_value' => (string) $item], $value);
|
||||
}
|
||||
} else {
|
||||
$this->editValue = $content->getTranslation('value', $locale) ?? '';
|
||||
if (is_array($this->editValue)) {
|
||||
$this->editValue = json_encode($this->editValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$addJsonItem = function () {
|
||||
if ($this->jsonIsStringArray) {
|
||||
$this->jsonItems[] = ['_value' => ''];
|
||||
} elseif (!empty($this->jsonItems)) {
|
||||
$template = array_map(fn() => '', $this->jsonItems[0]);
|
||||
$this->jsonItems[] = $template;
|
||||
}
|
||||
};
|
||||
|
||||
$removeJsonItem = function (int $index) {
|
||||
unset($this->jsonItems[$index]);
|
||||
$this->jsonItems = array_values($this->jsonItems);
|
||||
};
|
||||
|
||||
$saveJsonModal = function () {
|
||||
$content = CmsContent::find($this->editingId);
|
||||
if (!$content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->jsonIsStringArray) {
|
||||
$value = array_values(array_map(fn($item) => $item['_value'] ?? '', $this->jsonItems));
|
||||
} else {
|
||||
$value = array_values(
|
||||
array_map(function ($item) {
|
||||
$cleaned = [];
|
||||
foreach ($item as $k => $v) {
|
||||
if (str_starts_with($v, '[') || str_starts_with($v, '{')) {
|
||||
$decoded = json_decode($v, true);
|
||||
$cleaned[$k] = json_last_error() === JSON_ERROR_NONE ? $decoded : $v;
|
||||
} else {
|
||||
$cleaned[$k] = $v;
|
||||
}
|
||||
}
|
||||
return $cleaned;
|
||||
}, $this->jsonItems),
|
||||
);
|
||||
}
|
||||
|
||||
$content->setTranslation('value', $this->editLocale, $value);
|
||||
$content->save();
|
||||
|
||||
app(CmsContentService::class)->clearCache($this->selectedGroup);
|
||||
|
||||
$this->showJsonModal = false;
|
||||
$this->editingId = null;
|
||||
$this->jsonItems = [];
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'JSON-Inhalt wurde erfolgreich aktualisiert.');
|
||||
};
|
||||
|
||||
$saveEdit = function () {
|
||||
$content = CmsContent::find($this->editingId);
|
||||
if (!$content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$content->setTranslation('value', $this->editLocale, $this->editValue);
|
||||
$content->save();
|
||||
|
||||
app(CmsContentService::class)->clearCache($this->selectedGroup);
|
||||
|
||||
$this->editingId = null;
|
||||
$this->editValue = '';
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
|
||||
};
|
||||
|
||||
$cancelEdit = fn() => ($this->editingId = null);
|
||||
|
||||
$cancelJsonModal = function () {
|
||||
$this->showJsonModal = false;
|
||||
$this->editingId = null;
|
||||
$this->jsonItems = [];
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Inhalte verwalten</flux:heading>
|
||||
<div class="flex items-center gap-2">
|
||||
<flux:badge color="blue">{{ array_sum($this->groups) }} Einträge</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||
{{-- Sidebar: Groups --}}
|
||||
<div class="lg:col-span-1">
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-3">Seiten / Gruppen</flux:heading>
|
||||
<div class="flex flex-col gap-1">
|
||||
@foreach ($this->groups as $group => $count)
|
||||
<button wire:click="selectGroup('{{ $group }}')"
|
||||
class="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors {{ $selectedGroup === $group ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
|
||||
<span>{{ $group }}</span>
|
||||
<flux:badge size="sm">{{ $count }}</flux:badge>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Main: Content Editor --}}
|
||||
<div class="lg:col-span-3">
|
||||
@if ($selectedGroup)
|
||||
<flux:card>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<flux:heading size="lg">{{ $selectedGroup }}</flux:heading>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." size="sm"
|
||||
icon="magnifying-glass" class="w-48" />
|
||||
<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>
|
||||
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->contents as $content)
|
||||
<div wire:key="content-{{ $content->id }}" class="py-3">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<code
|
||||
class="rounded bg-zinc-100 text-zinc-400 px-1.5 py-0.5 text-xs dark:bg-zinc-700 dark:text-zinc-400">{{ $content->key }}</code>
|
||||
<flux:badge size="sm"
|
||||
:color="match($content->type) { 'html' => 'amber', 'image' => 'green', 'json' => 'violet', 'link' => 'rose', default => 'zinc' }">
|
||||
{{ $content->type }}</flux:badge>
|
||||
</div>
|
||||
|
||||
@if ($editingId === $content->id && $content->type === 'image')
|
||||
<div class="mt-2">
|
||||
<div class="flex items-start gap-4">
|
||||
@if ($editValue)
|
||||
<div class="h-24 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($editValue) }}" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex-1">
|
||||
<livewire:admin.cms.media-picker
|
||||
:value="$editMediaId"
|
||||
field="content_image"
|
||||
type="image"
|
||||
profile="thumbnail"
|
||||
label="Bild wählen"
|
||||
:key="'content-img-' . $editingId"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-zinc-400">{{ $editValue ?: 'Kein Bild ausgewählt' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<flux:button size="sm" variant="primary" wire:click="saveEdit">
|
||||
Speichern</flux:button>
|
||||
<flux:button size="sm" variant="ghost" wire:click="cancelEdit">
|
||||
Abbrechen</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($editingId === $content->id && $content->type !== 'json')
|
||||
<div class="mt-2">
|
||||
@if (in_array($selectedGroup, ['datenschutz', 'impressum']))
|
||||
<flux:editor wire:model="editValue"
|
||||
toolbar="heading | bold italic underline strike | bullet ordered blockquote | link"
|
||||
class="**:data-[slot=content]:min-h-[80px]!" />
|
||||
@else
|
||||
<flux:editor wire:model="editValue" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[60px]!" />
|
||||
@endif
|
||||
<div class="mt-2 flex gap-2">
|
||||
<flux:button size="sm" variant="primary" wire:click="saveEdit">
|
||||
Speichern</flux:button>
|
||||
<flux:button size="sm" variant="ghost" wire:click="cancelEdit">
|
||||
Abbrechen</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$displayValue = $content->getTranslation('value', $editLocale);
|
||||
$displayStr = is_array($displayValue)
|
||||
? json_encode($displayValue, JSON_UNESCAPED_UNICODE)
|
||||
: (string) $displayValue;
|
||||
@endphp
|
||||
@if ($content->type === 'image')
|
||||
<div class="flex items-center gap-3">
|
||||
@if ($displayStr)
|
||||
<div class="h-10 w-10 shrink-0 overflow-hidden rounded border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<img src="{{ media_url($displayStr) }}" class="h-full w-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
@endif
|
||||
<span class="text-sm text-zinc-600 dark:text-zinc-400">{{ $displayStr ?: 'Kein Bild' }}</span>
|
||||
</div>
|
||||
@elseif ($content->type === 'json')
|
||||
@php
|
||||
$jsonVal = $content->getTranslation('value', $editLocale);
|
||||
$itemCount = is_array($jsonVal) ? count($jsonVal) : 0;
|
||||
$firstItem =
|
||||
is_array($jsonVal) && !empty($jsonVal) ? $jsonVal[0] : null;
|
||||
$isObjects = is_array($firstItem);
|
||||
@endphp
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<flux:badge size="sm" color="violet">{{ $itemCount }}
|
||||
Einträge</flux:badge>
|
||||
@if ($isObjects && is_array($firstItem))
|
||||
<span class="text-xs text-zinc-400">Felder:
|
||||
{{ implode(', ', array_keys($firstItem)) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@elseif ($content->type === 'html')
|
||||
<div
|
||||
class="prose prose-sm max-w-none text-zinc-800 dark:prose-invert dark:text-zinc-200 line-clamp-2">
|
||||
{!! \Illuminate\Support\Str::limit($displayStr, 200) !!}
|
||||
</div>
|
||||
@else
|
||||
<p class="truncate text-sm text-zinc-800 dark:text-zinc-200">
|
||||
{{ \Illuminate\Support\Str::limit(strip_tags($displayStr), 120) }}
|
||||
</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($editingId !== $content->id)
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="startEdit({{ $content->id }})" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine Einträge gefunden.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
@else
|
||||
<flux:card>
|
||||
<div class="py-12 text-center">
|
||||
<flux:icon name="document-text" class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
|
||||
<flux:heading>Seite auswählen</flux:heading>
|
||||
<flux:text>Wähle links eine Seite/Gruppe aus, um deren Inhalte zu bearbeiten.</flux:text>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- JSON Editor Modal --}}
|
||||
<flux:modal wire:model="showJsonModal" class="w-full max-w-5xl space-y-6 overflow-y-auto max-h-[90vh]">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $jsonEditingKey }}</flux:heading>
|
||||
<flux:text class="mt-1">
|
||||
{{ $jsonIsStringArray ? 'Einfache Liste' : 'Strukturierte Einträge' }}
|
||||
({{ count($jsonItems) }} Einträge) — {{ strtoupper($editLocale) }}
|
||||
</flux:text>
|
||||
</div>
|
||||
|
||||
<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
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
@foreach ($jsonItems as $idx => $item)
|
||||
<div wire:key="json-item-{{ $idx }}"
|
||||
class="rounded-lg border border-zinc-200 dark:border-zinc-700 p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-zinc-500">Eintrag {{ $idx + 1 }}</span>
|
||||
<flux:button size="xs" variant="ghost" icon="trash"
|
||||
wire:click="removeJsonItem({{ $idx }})"
|
||||
wire:confirm="Eintrag {{ $idx + 1 }} wirklich entfernen?" />
|
||||
</div>
|
||||
|
||||
@if ($jsonIsStringArray)
|
||||
<flux:input wire:model="jsonItems.{{ $idx }}._value" placeholder="Wert" />
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
@foreach ($item as $field => $fieldValue)
|
||||
@php
|
||||
$isIcon = in_array($field, ['icon']);
|
||||
$isRichText = in_array($field, [
|
||||
'description',
|
||||
'text',
|
||||
'content',
|
||||
'help',
|
||||
'answer',
|
||||
'quote',
|
||||
]);
|
||||
$isLongText = in_array($field, ['tagline']);
|
||||
$isNestedJson =
|
||||
is_string($fieldValue) &&
|
||||
(str_starts_with($fieldValue, '[') || str_starts_with($fieldValue, '{'));
|
||||
@endphp
|
||||
|
||||
@if ($isIcon)
|
||||
<div class="md:col-span-2">
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<flux:select
|
||||
wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||
variant="listbox" searchable label="{{ ucfirst($field) }}"
|
||||
placeholder="Icon auswählen...">
|
||||
<flux:select.option value="">— Kein Icon —
|
||||
</flux:select.option>
|
||||
@foreach ($this->availableIcons as $iconName)
|
||||
<flux:select.option value="{{ $iconName }}">
|
||||
{{ $iconName }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
@if (!empty($fieldValue))
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<x-dynamic-component :component="'heroicon-o-' . $fieldValue"
|
||||
class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($isRichText)
|
||||
<div class="md:col-span-2">
|
||||
<flux:editor wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||
label="{{ ucfirst($field) }}" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[60px]!" />
|
||||
</div>
|
||||
@elseif ($isNestedJson)
|
||||
<div class="md:col-span-2">
|
||||
<flux:textarea wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||
label="{{ ucfirst($field) }} (JSON)" rows="3"
|
||||
class="font-mono text-xs" />
|
||||
</div>
|
||||
@else
|
||||
<flux:input wire:model="jsonItems.{{ $idx }}.{{ $field }}"
|
||||
label="{{ ucfirst($field) }}" />
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-zinc-200 dark:border-zinc-700 pt-4">
|
||||
<flux:button size="sm" variant="ghost" icon="plus" wire:click="addJsonItem">
|
||||
Eintrag hinzufügen
|
||||
</flux:button>
|
||||
<div class="flex gap-2">
|
||||
<flux:button variant="ghost" wire:click="cancelJsonModal">Abbrechen</flux:button>
|
||||
<flux:button variant="primary" wire:click="saveJsonModal">Speichern</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Models\CmsDownload;
|
||||
use FluxCms\Core\Models\CmsFaq;
|
||||
use FluxCms\Core\Models\CmsIndustry;
|
||||
use FluxCms\Core\Models\CmsLinkedinPost;
|
||||
use FluxCms\Core\Models\CmsNewsItem;
|
||||
use function Livewire\Volt\{computed};
|
||||
|
||||
$stats = computed(
|
||||
fn() => [
|
||||
'contents' => CmsContent::count(),
|
||||
'groups' => CmsContent::distinct()->pluck('group')->count(),
|
||||
'news' => CmsNewsItem::count(),
|
||||
'industries' => CmsIndustry::count(),
|
||||
'faqs' => CmsFaq::count(),
|
||||
'linkedin' => CmsLinkedinPost::count(),
|
||||
'downloads' => CmsDownload::count(),
|
||||
],
|
||||
);
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<flux:heading size="xl" class="mb-6">CMS Dashboard</flux:heading>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<a href="{{ route('cms.content.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-blue-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="document-text" class="text-blue-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['contents'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Inhalte in {{ $this->stats['groups'] }} Gruppen</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('cms.news.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-green-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="newspaper" class="text-green-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['news'] }}</flux:heading>
|
||||
<flux:text class="text-sm">News Items</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('cms.faqs.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-amber-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="question-mark-circle" class="text-amber-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['faqs'] }}</flux:heading>
|
||||
<flux:text class="text-sm">FAQ Einträge</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('cms.linkedin.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-sky-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="chat-bubble-left-right" class="text-sky-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['linkedin'] }}</flux:heading>
|
||||
<flux:text class="text-sm">LinkedIn Beiträge</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('cms.industries.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-violet-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="building-office" class="text-violet-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['industries'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Industries</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
|
||||
<a href="{{ route('cms.downloads.index') }}" wire:navigate>
|
||||
<flux:card class="hover:border-rose-500 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="arrow-down-tray" class="text-rose-500" />
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $this->stats['downloads'] }}</flux:heading>
|
||||
<flux:text class="text-sm">Downloads</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,408 @@
|
|||
<?php
|
||||
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsDownload;
|
||||
use FluxCms\Core\Services\HeroiconOutlineList;
|
||||
use function Livewire\Volt\{state, computed, layout, on};
|
||||
|
||||
layout('components.layouts.cms');
|
||||
|
||||
state([
|
||||
'editLocale' => 'de',
|
||||
'showForm' => false,
|
||||
'editingId' => null,
|
||||
'filterCategory' => '',
|
||||
'title' => '',
|
||||
'description' => '',
|
||||
'category' => 'case_study',
|
||||
'icon' => 'document-text',
|
||||
'sub_category' => '',
|
||||
'type_label' => '',
|
||||
'alt' => '',
|
||||
'file_path' => '',
|
||||
'fileMediaId' => null,
|
||||
'thumbnail' => '',
|
||||
'thumbMediaId' => null,
|
||||
'open_text' => '',
|
||||
'download_text' => '',
|
||||
'highlights' => [],
|
||||
'checkpoints' => [],
|
||||
]);
|
||||
|
||||
$downloads = computed(function () {
|
||||
$query = CmsDownload::ordered();
|
||||
if ($this->filterCategory) {
|
||||
$query->byCategory($this->filterCategory);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
});
|
||||
|
||||
$availableIcons = computed(fn () => HeroiconOutlineList::names());
|
||||
|
||||
$create = function () {
|
||||
$this->reset(['editingId', 'title', 'description', 'icon', 'sub_category', 'type_label', 'alt', 'file_path', 'fileMediaId', 'thumbnail', 'thumbMediaId', 'open_text', 'download_text', 'highlights', 'checkpoints']);
|
||||
$this->category = 'case_study';
|
||||
$this->icon = 'document-text';
|
||||
$this->highlights = [];
|
||||
$this->checkpoints = [];
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$edit = function (int $id) {
|
||||
$dl = CmsDownload::findOrFail($id);
|
||||
$this->editingId = $id;
|
||||
$this->showForm = true;
|
||||
$l = $this->editLocale;
|
||||
$this->title = $dl->getTranslation('title', $l) ?? '';
|
||||
$this->description = $dl->getTranslation('description', $l) ?? '';
|
||||
$this->category = $dl->category;
|
||||
$this->icon = $dl->icon ?? 'document-text';
|
||||
$this->sub_category = $dl->sub_category ?? '';
|
||||
$this->type_label = $dl->getTranslation('type_label', $l) ?? '';
|
||||
$this->alt = $dl->getTranslation('alt', $l) ?? '';
|
||||
$this->file_path = $dl->getTranslation('file_path', $l) ?? '';
|
||||
$this->thumbnail = $dl->thumbnail ?? '';
|
||||
$this->open_text = $dl->getTranslation('open_text', $l) ?? '';
|
||||
$this->download_text = $dl->getTranslation('download_text', $l) ?? '';
|
||||
$this->highlights = is_array($dl->highlights) ? $dl->highlights : [];
|
||||
$this->checkpoints = is_array($dl->checkpoints) ? $dl->checkpoints : [];
|
||||
$this->thumbMediaId = $this->thumbnail ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->thumbnail)->first()?->id : null;
|
||||
$this->fileMediaId = $this->file_path ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->file_path)->first()?->id : null;
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$existing = $this->editingId ? CmsDownload::find($this->editingId) : null;
|
||||
$merge = function (string $field, ?string $value) use ($existing) {
|
||||
$t = $existing ? $existing->getTranslations($field) : [];
|
||||
$t[$this->editLocale] = $value ?? '';
|
||||
|
||||
return $t;
|
||||
};
|
||||
|
||||
$data = [
|
||||
'title' => $merge('title', $this->title),
|
||||
'description' => $merge('description', $this->description),
|
||||
'category' => $this->category,
|
||||
'icon' => $this->icon,
|
||||
'sub_category' => $this->sub_category,
|
||||
'type_label' => $merge('type_label', $this->type_label),
|
||||
'alt' => $merge('alt', $this->alt),
|
||||
'file_path' => $merge('file_path', $this->file_path),
|
||||
'thumbnail' => $this->thumbnail,
|
||||
'open_text' => $merge('open_text', $this->open_text),
|
||||
'download_text' => $merge('download_text', $this->download_text),
|
||||
'highlights' => array_values(array_filter($this->highlights, fn($h) => !empty($h['value']) || !empty($h['label']))),
|
||||
'checkpoints' => array_values(array_filter($this->checkpoints, fn($c) => !empty($c['value']))),
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($data);
|
||||
} else {
|
||||
$data['order'] = CmsDownload::max('order') + 1;
|
||||
$data['is_published'] = true;
|
||||
CmsDownload::create($data);
|
||||
}
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Download wurde erfolgreich gespeichert.');
|
||||
};
|
||||
|
||||
$delete = function (int $id) {
|
||||
CmsDownload::findOrFail($id)->delete();
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Download wurde entfernt.');
|
||||
};
|
||||
|
||||
$togglePublished = function (int $id) {
|
||||
$dl = CmsDownload::findOrFail($id);
|
||||
$dl->update(['is_published' => !$dl->is_published]);
|
||||
Flux::toast(heading: 'Status geändert', text: $dl->is_published ? 'Veröffentlicht' : 'Entwurf');
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$dl = CmsDownload::find($this->editingId);
|
||||
if ($dl) {
|
||||
$this->title = $dl->getTranslation('title', $locale) ?? '';
|
||||
$this->description = $dl->getTranslation('description', $locale) ?? '';
|
||||
$this->type_label = $dl->getTranslation('type_label', $locale) ?? '';
|
||||
$this->alt = $dl->getTranslation('alt', $locale) ?? '';
|
||||
$this->file_path = $dl->getTranslation('file_path', $locale) ?? '';
|
||||
$this->open_text = $dl->getTranslation('open_text', $locale) ?? '';
|
||||
$this->download_text = $dl->getTranslation('download_text', $locale) ?? '';
|
||||
$this->fileMediaId = $this->file_path ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->file_path)->first()?->id : null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$cancel = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
$addHighlight = function () {
|
||||
$this->highlights[] = ['value' => '', 'label' => ''];
|
||||
};
|
||||
|
||||
$removeHighlight = function (int $index) {
|
||||
unset($this->highlights[$index]);
|
||||
$this->highlights = array_values($this->highlights);
|
||||
};
|
||||
|
||||
$addCheckpoint = function () {
|
||||
$this->checkpoints[] = ['value' => ''];
|
||||
};
|
||||
|
||||
$removeCheckpoint = function (int $index) {
|
||||
unset($this->checkpoints[$index]);
|
||||
$this->checkpoints = array_values($this->checkpoints);
|
||||
};
|
||||
|
||||
$moveUp = function (int $id) {
|
||||
$items = CmsDownload::ordered()->get();
|
||||
$idx = $items->search(fn($i) => $i->id === $id);
|
||||
if ($idx > 0) {
|
||||
$prev = $items[$idx - 1];
|
||||
$curr = $items[$idx];
|
||||
[$prev->order, $curr->order] = [$curr->order, $prev->order];
|
||||
$prev->save();
|
||||
$curr->save();
|
||||
}
|
||||
};
|
||||
|
||||
$moveDown = function (int $id) {
|
||||
$items = CmsDownload::ordered()->get();
|
||||
$idx = $items->search(fn($i) => $i->id === $id);
|
||||
if ($idx !== false && $idx < $items->count() - 1) {
|
||||
$next = $items[$idx + 1];
|
||||
$curr = $items[$idx];
|
||||
[$next->order, $curr->order] = [$curr->order, $next->order];
|
||||
$next->save();
|
||||
$curr->save();
|
||||
}
|
||||
};
|
||||
|
||||
on([
|
||||
'media-selected' => function (string $field, ?int $mediaId, ?string $url) {
|
||||
if ($field === 'dl_file') {
|
||||
$this->fileMediaId = $mediaId;
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
$this->file_path = $media ? $media->filename : '';
|
||||
} elseif ($field === 'dl_thumb') {
|
||||
$this->thumbMediaId = $mediaId;
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
$this->thumbnail = $media ? $media->filename : '';
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Downloads</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="create">Neu</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Category Filter --}}
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
<flux:button size="xs" :variant="$filterCategory === '' ? 'primary' : 'ghost'"
|
||||
wire:click="$set('filterCategory', '')">Alle</flux:button>
|
||||
<flux:button size="xs" :variant="$filterCategory === 'case_study' ? 'primary' : 'ghost'"
|
||||
wire:click="$set('filterCategory', 'case_study')">Case Studies</flux:button>
|
||||
<flux:button size="xs" :variant="$filterCategory === 'capability' ? 'primary' : 'ghost'"
|
||||
wire:click="$set('filterCategory', 'capability')">Capabilities</flux:button>
|
||||
<flux:button size="xs" :variant="$filterCategory === 'success_story' ? 'primary' : 'ghost'"
|
||||
wire:click="$set('filterCategory', 'success_story')">Success Stories</flux:button>
|
||||
</div>
|
||||
|
||||
@if ($showForm)
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'Bearbeiten' : 'Neuer Download' }}
|
||||
</flux:heading>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:input wire:model="title" label="Titel" />
|
||||
<flux:select wire:model="category" label="Kategorie">
|
||||
<flux:select.option value="case_study">Case Study</flux:select.option>
|
||||
<flux:select.option value="capability">Capability</flux:select.option>
|
||||
<flux:select.option value="success_story">Success Story</flux:select.option>
|
||||
</flux:select>
|
||||
<flux:input wire:model="sub_category" label="Unterkategorie" placeholder="z.B. R&D Product Support" />
|
||||
<flux:input wire:model="type_label" label="Typ-Label" placeholder="z.B. Case Study" />
|
||||
<flux:input wire:model="alt" label="Alt-Text (Bild)" />
|
||||
<div>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<flux:select wire:model="icon" variant="listbox" searchable label="Icon"
|
||||
placeholder="Icon auswählen...">
|
||||
@foreach ($this->availableIcons as $iconName)
|
||||
<flux:select.option value="{{ $iconName }}">{{ $iconName }}
|
||||
</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
@if ($icon)
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<x-dynamic-component :component="'heroicon-o-' . $icon" class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Vorschaubild + PDF --}}
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Vorschaubild</label>
|
||||
<div class="flex items-start gap-3">
|
||||
@if ($thumbnail)
|
||||
<div
|
||||
class="h-16 w-16 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($thumbnail) }}" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex-1">
|
||||
<livewire:admin.cms.media-picker :value="$thumbMediaId" field="dl_thumb" type="image"
|
||||
profile="thumbnail" label="Bild wählen" :key="'dl-thumb-' . ($editingId ?? 'new')" />
|
||||
<p class="mt-1 text-xs text-zinc-400">{{ $thumbnail ?: 'Kein Bild' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">PDF-Datei ({{ strtoupper($editLocale) }})</label>
|
||||
@if ($file_path)
|
||||
<div class="mb-2 overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<iframe src="{{ media_url($file_path) }}#toolbar=0&navpanes=0" class="h-48 w-full bg-white"
|
||||
loading="lazy"></iframe>
|
||||
</div>
|
||||
<p class="mb-1 text-xs text-zinc-400">{{ $file_path }}</p>
|
||||
@endif
|
||||
<livewire:admin.cms.media-picker :value="$fileMediaId" field="dl_file" type="pdf" profile=""
|
||||
label="PDF wählen" :key="'dl-file-' . ($editingId ?? 'new') . '-' . $editLocale" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Button-Texte --}}
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:input wire:model="open_text" label="PDF öffnen Text" placeholder="PDF öffnen" />
|
||||
<flux:input wire:model="download_text" label="PDF downloaden Text" placeholder="PDF downloaden" />
|
||||
</div>
|
||||
|
||||
{{-- Beschreibung --}}
|
||||
<div class="mt-4">
|
||||
<flux:editor wire:model="description" label="Beschreibung" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[80px]!" />
|
||||
</div>
|
||||
|
||||
{{-- Highlights (Case Studies / Success Stories) --}}
|
||||
@if ($category === 'case_study' || $category === 'success_story')
|
||||
<div class="mt-4">
|
||||
<label class="mb-2 block text-sm font-medium">Highlights (Kennzahlen)</label>
|
||||
<div class="space-y-2">
|
||||
@foreach ($highlights as $hIdx => $highlight)
|
||||
<div wire:key="hl-{{ $hIdx }}" class="flex items-center gap-2">
|
||||
<flux:input wire:model="highlights.{{ $hIdx }}.value"
|
||||
placeholder="Wert (z.B. 100%)" class="w-32!" />
|
||||
<flux:input wire:model="highlights.{{ $hIdx }}.label" placeholder="Label"
|
||||
class="flex-1!" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="removeHighlight({{ $hIdx }})" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addHighlight"
|
||||
class="mt-2">
|
||||
Highlight hinzufügen</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Checkpoints (Capabilities) --}}
|
||||
@if ($category === 'capability')
|
||||
<div class="mt-4">
|
||||
<label class="mb-2 block text-sm font-medium">Checkpoints</label>
|
||||
<div class="space-y-2">
|
||||
@foreach ($checkpoints as $cIdx => $checkpoint)
|
||||
<div wire:key="cp-{{ $cIdx }}" class="flex items-center gap-2">
|
||||
<flux:input wire:model="checkpoints.{{ $cIdx }}.value"
|
||||
placeholder="Checkpoint-Text" class="flex-1!" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="removeCheckpoint({{ $cIdx }})" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addCheckpoint"
|
||||
class="mt-2">Checkpoint hinzufügen</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->downloads as $dl)
|
||||
<div wire:key="dl-{{ $dl->id }}" class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
@if ($dl->thumbnail)
|
||||
<div class="h-12 w-12 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
|
||||
<img src="{{ media_url($dl->thumbnail) }}" alt=""
|
||||
class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@elseif ($dl->icon)
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<x-dynamic-component :component="'heroicon-o-' . $dl->icon" class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ $dl->getTranslation('title', $editLocale) }}</span>
|
||||
<flux:badge size="sm"
|
||||
:color="$dl->category === 'case_study' ? 'blue' : ($dl->category === 'capability' ? 'green' : 'purple')">
|
||||
{{ $dl->category === 'case_study' ? 'Case Study' : ($dl->category === 'capability' ? 'Capability' : 'Success Story') }}
|
||||
</flux:badge>
|
||||
@unless ($dl->is_published)
|
||||
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
|
||||
@endunless
|
||||
</div>
|
||||
<p class="truncate text-sm text-zinc-500">
|
||||
{{ $dl->sub_category }}
|
||||
@if ($dl->getTranslation('file_path', $editLocale))
|
||||
· {{ $dl->getTranslation('file_path', $editLocale) }}
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="chevron-up"
|
||||
wire:click="moveUp({{ $dl->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="chevron-down"
|
||||
wire:click="moveDown({{ $dl->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="edit({{ $dl->id }})" />
|
||||
<flux:button size="sm" variant="ghost" :icon="$dl->is_published ? 'eye' : 'eye-slash'"
|
||||
wire:click="togglePublished({{ $dl->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="delete({{ $dl->id }})" wire:confirm="Wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine Downloads vorhanden.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
<?php
|
||||
|
||||
use Flux\Flux;
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use FluxCms\Core\Models\CmsFaq;
|
||||
use function Livewire\Volt\{state, computed};
|
||||
|
||||
state([
|
||||
'editLocale' => 'de',
|
||||
'selectedCategory' => null,
|
||||
'showForm' => false,
|
||||
'editingId' => null,
|
||||
'question' => '',
|
||||
'answer' => '',
|
||||
'help' => '',
|
||||
'category' => '',
|
||||
]);
|
||||
|
||||
$categories = computed(fn() => CmsFaq::query()->selectRaw('category, count(*) as count')->groupBy('category')->orderBy('category')->pluck('count', 'category')->toArray());
|
||||
|
||||
$faqs = computed(fn() => $this->selectedCategory ? CmsFaq::byCategory($this->selectedCategory)->ordered()->get() : collect());
|
||||
|
||||
$selectCategory = function (string $cat) {
|
||||
$this->selectedCategory = $cat;
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
$create = function () {
|
||||
$this->resetForm();
|
||||
$this->category = $this->selectedCategory ?? '';
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$edit = function (int $id) {
|
||||
$faq = CmsFaq::findOrFail($id);
|
||||
$this->editingId = $id;
|
||||
$this->showForm = true;
|
||||
$l = $this->editLocale;
|
||||
$this->question = $faq->getTranslation('question', $l) ?? '';
|
||||
$this->answer = $faq->getTranslation('answer', $l) ?? '';
|
||||
$this->help = $faq->getTranslation('help', $l) ?? '';
|
||||
$this->category = $faq->category;
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$existing = $this->editingId ? CmsFaq::find($this->editingId) : null;
|
||||
|
||||
$data = [
|
||||
'category' => $this->category,
|
||||
'question' => $this->mergeTranslation($existing, 'question', $this->question),
|
||||
'answer' => $this->mergeTranslation($existing, 'answer', $this->answer),
|
||||
'help' => $this->help ? $this->mergeTranslation($existing, 'help', $this->help) : null,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($data);
|
||||
} else {
|
||||
$data['order'] = CmsFaq::where('category', $this->category)->max('order') + 1;
|
||||
$data['is_published'] = true;
|
||||
CmsFaq::create($data);
|
||||
}
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'FAQ wurde erfolgreich gespeichert.');
|
||||
};
|
||||
|
||||
$delete = function (int $id) {
|
||||
CmsFaq::findOrFail($id)->delete();
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'FAQ wurde entfernt.');
|
||||
};
|
||||
|
||||
$togglePublished = function (int $id) {
|
||||
$faq = CmsFaq::findOrFail($id);
|
||||
$faq->update(['is_published' => !$faq->is_published]);
|
||||
Flux::toast(heading: 'Status geändert', text: $faq->is_published ? 'Veröffentlicht' : 'Entwurf');
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$faq = CmsFaq::find($this->editingId);
|
||||
if ($faq) {
|
||||
$this->question = $faq->getTranslation('question', $locale) ?? '';
|
||||
$this->answer = $faq->getTranslation('answer', $locale) ?? '';
|
||||
$this->help = $faq->getTranslation('help', $locale) ?? '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$cancel = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
$resetForm = function () {
|
||||
$this->editingId = null;
|
||||
$this->question = '';
|
||||
$this->answer = '';
|
||||
$this->help = '';
|
||||
};
|
||||
|
||||
$mergeTranslation = function (?CmsFaq $model, string $field, string $value): array {
|
||||
$existing = $model ? $model->getTranslations($field) : [];
|
||||
$existing[$this->editLocale] = $value;
|
||||
return $existing;
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">FAQs</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="create">Neu</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||
<div class="lg:col-span-1">
|
||||
<flux:card>
|
||||
<flux:heading size="sm" class="mb-3">Kategorien</flux:heading>
|
||||
<div class="flex flex-col gap-1">
|
||||
@foreach ($this->categories as $cat => $count)
|
||||
<button wire:click="selectCategory('{{ $cat }}')"
|
||||
class="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors {{ $selectedCategory === $cat ? 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' : 'hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
|
||||
<span>{{ $cat }}</span>
|
||||
<flux:badge size="sm">{{ $count }}</flux:badge>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-3">
|
||||
@if ($showForm)
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'FAQ bearbeiten' : 'Neue FAQ' }}
|
||||
</flux:heading>
|
||||
<div class="space-y-4">
|
||||
<flux:input wire:model="category" label="Kategorie" />
|
||||
<flux:input wire:model="question" label="Frage" />
|
||||
<flux:editor wire:model="answer" label="Antwort" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[80px]!" />
|
||||
<flux:editor wire:model="help" label="Hilfe-Text (optional)" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[80px]!" />
|
||||
<div class="flex gap-2">
|
||||
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
@if ($selectedCategory)
|
||||
<flux:card>
|
||||
<flux:heading size="lg" class="mb-4">{{ $selectedCategory }}</flux:heading>
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->faqs as $faq)
|
||||
<div wire:key="faq-{{ $faq->id }}"
|
||||
class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium">{{ $faq->getTranslation('question', $editLocale) }}</p>
|
||||
<p class="truncate text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ \Illuminate\Support\Str::limit(strip_tags($faq->getTranslation('answer', $editLocale) ?? ''), 100) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="edit({{ $faq->id }})" />
|
||||
<flux:button size="sm" variant="ghost"
|
||||
:icon="$faq->is_published ? 'eye' : 'eye-slash'"
|
||||
wire:click="togglePublished({{ $faq->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="delete({{ $faq->id }})" wire:confirm="Wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine FAQs in dieser Kategorie.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
@else
|
||||
<flux:card>
|
||||
<div class="py-12 text-center">
|
||||
<flux:icon name="question-mark-circle" class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
|
||||
<flux:heading>Kategorie auswählen</flux:heading>
|
||||
<flux:text>Wähle links eine Kategorie, um FAQs zu bearbeiten.</flux:text>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
use Flux\Flux;
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use FluxCms\Core\Models\CmsIndustry;
|
||||
use function Livewire\Volt\{state, computed};
|
||||
|
||||
state([
|
||||
'editLocale' => 'de',
|
||||
'showForm' => false,
|
||||
'editingId' => null,
|
||||
'name' => '',
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
$industries = computed(fn () => CmsIndustry::ordered()->get());
|
||||
|
||||
$create = function () {
|
||||
$this->editingId = null;
|
||||
$this->name = '';
|
||||
$this->order = CmsIndustry::max('order') + 1;
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$edit = function (int $id) {
|
||||
$item = CmsIndustry::findOrFail($id);
|
||||
$this->editingId = $id;
|
||||
$this->name = $item->getTranslation('name', $this->editLocale) ?? '';
|
||||
$this->order = $item->order;
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$existing = $this->editingId ? CmsIndustry::find($this->editingId) : null;
|
||||
$translations = $existing ? $existing->getTranslations('name') : [];
|
||||
$translations[$this->editLocale] = $this->name;
|
||||
|
||||
if ($existing) {
|
||||
$existing->update(['name' => $translations, 'order' => (int) $this->order]);
|
||||
} else {
|
||||
CmsIndustry::create([
|
||||
'name' => $translations,
|
||||
'order' => (int) $this->order,
|
||||
'is_published' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
$this->name = '';
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Industrie wurde erfolgreich gespeichert.');
|
||||
};
|
||||
|
||||
$delete = function (int $id) {
|
||||
CmsIndustry::findOrFail($id)->delete();
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Industrie wurde entfernt.');
|
||||
};
|
||||
|
||||
$togglePublished = function (int $id) {
|
||||
$item = CmsIndustry::findOrFail($id);
|
||||
$item->update(['is_published' => !$item->is_published]);
|
||||
Flux::toast(heading: 'Status geändert', text: $item->is_published ? 'Veröffentlicht' : 'Entwurf');
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$item = CmsIndustry::find($this->editingId);
|
||||
$this->name = $item?->getTranslation('name', $locale) ?? '';
|
||||
}
|
||||
};
|
||||
|
||||
$moveUp = function (int $id) {
|
||||
$items = CmsIndustry::ordered()->get();
|
||||
$index = $items->search(fn($i) => $i->id === $id);
|
||||
|
||||
if ($index === false || $index === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prev = $items[$index - 1];
|
||||
$current = $items[$index];
|
||||
|
||||
$tmpOrder = $prev->order;
|
||||
$prev->update(['order' => $current->order]);
|
||||
$current->update(['order' => $tmpOrder]);
|
||||
|
||||
Flux::toast(heading: 'Verschoben', text: 'Position geändert.');
|
||||
};
|
||||
|
||||
$moveDown = function (int $id) {
|
||||
$items = CmsIndustry::ordered()->get();
|
||||
$index = $items->search(fn($i) => $i->id === $id);
|
||||
|
||||
if ($index === false || $index >= $items->count() - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$next = $items[$index + 1];
|
||||
$current = $items[$index];
|
||||
|
||||
$tmpOrder = $next->order;
|
||||
$next->update(['order' => $current->order]);
|
||||
$current->update(['order' => $tmpOrder]);
|
||||
|
||||
Flux::toast(heading: 'Verschoben', text: 'Position geändert.');
|
||||
};
|
||||
|
||||
$cancel = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Industries Band</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="create">Neu</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($showForm)
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'Bearbeiten' : 'Neue Industry' }}</flux:heading>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div class="md:col-span-3">
|
||||
<flux:input wire:model="name" label="Name" />
|
||||
</div>
|
||||
<flux:input wire:model="order" label="Reihenfolge" type="number" min="0" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->industries as $item)
|
||||
<div wire:key="industry-{{ $item->id }}" class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-zinc-400">{{ $item->order }}</span>
|
||||
<span class="font-medium">{{ $item->getTranslation('name', $editLocale) }}</span>
|
||||
@unless ($item->is_published)
|
||||
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
|
||||
@endunless
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="chevron-up"
|
||||
wire:click="moveUp({{ $item->id }})"
|
||||
:disabled="$loop->first" />
|
||||
<flux:button size="sm" variant="ghost" icon="chevron-down"
|
||||
wire:click="moveDown({{ $item->id }})"
|
||||
:disabled="$loop->last" />
|
||||
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="edit({{ $item->id }})" />
|
||||
<flux:button size="sm" variant="ghost" :icon="$item->is_published ? 'eye' : 'eye-slash'" wire:click="togglePublished({{ $item->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash" wire:click="delete({{ $item->id }})" wire:confirm="Wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine Industries vorhanden.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
<?php
|
||||
|
||||
use Flux\Flux;
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use FluxCms\Core\Models\CmsLinkedinPost;
|
||||
use function Livewire\Volt\{state, computed, on};
|
||||
|
||||
state([
|
||||
'editLocale' => 'de',
|
||||
'showForm' => false,
|
||||
'editingId' => null,
|
||||
'title' => '',
|
||||
'excerpt' => '',
|
||||
'content' => '',
|
||||
'author' => '',
|
||||
'date' => null,
|
||||
'url' => '',
|
||||
'image' => '',
|
||||
'imageMediaId' => null,
|
||||
'tags' => '',
|
||||
'source' => 'manual',
|
||||
]);
|
||||
|
||||
$posts = computed(fn() => CmsLinkedinPost::ordered()->get());
|
||||
|
||||
$create = function () {
|
||||
$this->reset(['editingId', 'title', 'excerpt', 'content', 'author', 'date', 'url', 'image', 'imageMediaId', 'tags', 'source']);
|
||||
$this->source = 'manual';
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$edit = function (int $id) {
|
||||
$post = CmsLinkedinPost::findOrFail($id);
|
||||
$this->editingId = $id;
|
||||
$this->showForm = true;
|
||||
$l = $this->editLocale;
|
||||
$this->title = $post->getTranslation('title', $l) ?? '';
|
||||
$this->excerpt = $post->getTranslation('excerpt', $l) ?? '';
|
||||
$this->content = $post->getTranslation('content', $l) ?? '';
|
||||
$this->author = $post->author ?? '';
|
||||
$this->date = $post->date?->format('Y-m-d');
|
||||
$this->url = $post->url ?? '';
|
||||
$this->image = $post->image ?? '';
|
||||
$this->imageMediaId = $this->image ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->image)->first()?->id : null;
|
||||
$this->tags = is_array($post->tags) ? implode(', ', $post->tags) : '';
|
||||
$this->source = $post->source;
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$existing = $this->editingId ? CmsLinkedinPost::find($this->editingId) : null;
|
||||
|
||||
$mergeT = function (string $field, string $value) use ($existing) {
|
||||
$t = $existing ? $existing->getTranslations($field) : [];
|
||||
$t[$this->editLocale] = $value;
|
||||
return $t;
|
||||
};
|
||||
|
||||
$tagsArray = array_map('trim', explode(',', $this->tags));
|
||||
$tagsArray = array_filter($tagsArray);
|
||||
|
||||
$data = [
|
||||
'title' => $mergeT('title', $this->title),
|
||||
'excerpt' => $mergeT('excerpt', $this->excerpt),
|
||||
'content' => $mergeT('content', $this->content),
|
||||
'author' => $this->author,
|
||||
'date' => $this->date ?: null,
|
||||
'url' => $this->url,
|
||||
'image' => $this->image,
|
||||
'tags' => array_values($tagsArray),
|
||||
'source' => $this->source,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($data);
|
||||
} else {
|
||||
$data['order'] = CmsLinkedinPost::max('order') + 1;
|
||||
$data['is_published'] = true;
|
||||
CmsLinkedinPost::create($data);
|
||||
}
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'LinkedIn-Post wurde erfolgreich gespeichert.');
|
||||
};
|
||||
|
||||
$delete = function (int $id) {
|
||||
CmsLinkedinPost::findOrFail($id)->delete();
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'LinkedIn-Post wurde entfernt.');
|
||||
};
|
||||
|
||||
$togglePublished = function (int $id) {
|
||||
$post = CmsLinkedinPost::findOrFail($id);
|
||||
$post->update(['is_published' => !$post->is_published]);
|
||||
Flux::toast(heading: 'Status geändert', text: $post->is_published ? 'Veröffentlicht' : 'Entwurf');
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$post = CmsLinkedinPost::find($this->editingId);
|
||||
if ($post) {
|
||||
$this->title = $post->getTranslation('title', $locale) ?? '';
|
||||
$this->excerpt = $post->getTranslation('excerpt', $locale) ?? '';
|
||||
$this->content = $post->getTranslation('content', $locale) ?? '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$cancel = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
on(['media-selected' => function (string $field, ?int $mediaId, ?string $url) {
|
||||
if ($field === 'linkedin_image') {
|
||||
$this->imageMediaId = $mediaId;
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
$this->image = $media ? $media->filename : '';
|
||||
}
|
||||
}]);
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">LinkedIn 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="create">Neu</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($showForm)
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'Bearbeiten' : 'Neuer LinkedIn Beitrag' }}
|
||||
</flux:heading>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:input wire:model="title" label="Titel" />
|
||||
<flux:input wire:model="author" label="Autor" />
|
||||
<flux:input wire:model="date" label="Datum" type="date" />
|
||||
<flux:input wire:model="url" label="LinkedIn URL" />
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Bild</label>
|
||||
<div class="flex items-start gap-3">
|
||||
@if ($image)
|
||||
<div class="h-16 w-16 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="$imageMediaId"
|
||||
field="linkedin_image"
|
||||
type="image"
|
||||
profile="news"
|
||||
label="Bild wählen"
|
||||
:key="'linkedin-img-' . ($editingId ?? 'new')" />
|
||||
<p class="mt-1 text-xs text-zinc-400">{{ $image ?: 'Kein Bild' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<flux:input wire:model="tags" label="Tags (kommagetrennt)" />
|
||||
<flux:select wire:model="source" label="Quelle">
|
||||
<flux:select.option value="manual">Manuell</flux:select.option>
|
||||
<flux:select.option value="api">API</flux:select.option>
|
||||
</flux:select>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<flux:editor wire:model="excerpt" label="Kurztext" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[60px]!" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<flux:editor wire:model="content" label="Inhalt"
|
||||
toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[120px]!" />
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->posts as $post)
|
||||
<div wire:key="linkedin-{{ $post->id }}" class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
@if ($post->image)
|
||||
<div class="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
|
||||
<img src="{{ media_url($post->image) }}" alt="" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ $post->getTranslation('title', $editLocale) }}</span>
|
||||
<flux:badge size="sm" :color="$post->source === 'api' ? 'blue' : 'zinc'">
|
||||
{{ $post->source }}</flux:badge>
|
||||
@unless ($post->is_published)
|
||||
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
|
||||
@endunless
|
||||
</div>
|
||||
<p class="text-sm text-zinc-600 dark:text-zinc-400">{{ $post->author }} ·
|
||||
{{ $post->date?->format('d.m.Y') }}</p>
|
||||
</div></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="edit({{ $post->id }})" />
|
||||
<flux:button size="sm" variant="ghost" :icon="$post->is_published ? 'eye' : 'eye-slash'"
|
||||
wire:click="togglePublished({{ $post->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="delete({{ $post->id }})" wire:confirm="Wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine LinkedIn Beiträge vorhanden.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,475 @@
|
|||
<?php
|
||||
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsMedia;
|
||||
use FluxCms\Core\Services\MediaConversionService;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use function Livewire\Volt\{state, computed, on};
|
||||
|
||||
state([
|
||||
'search' => '',
|
||||
'filterType' => 'all',
|
||||
'filterCollection' => '',
|
||||
'viewMode' => 'grid',
|
||||
'editingId' => null,
|
||||
'editLocale' => 'de',
|
||||
'altText' => '',
|
||||
'mediaTitle' => '',
|
||||
'collection' => '',
|
||||
'showDetail' => false,
|
||||
'selectedProfiles' => [],
|
||||
]);
|
||||
|
||||
$media = computed(
|
||||
fn() => CmsMedia::query()
|
||||
->when(
|
||||
$this->filterType !== 'all',
|
||||
fn($q) => match ($this->filterType) {
|
||||
'image' => $q->images(),
|
||||
'pdf' => $q->pdfs(),
|
||||
'document' => $q->documents(),
|
||||
default => $q,
|
||||
},
|
||||
)
|
||||
->when($this->filterCollection, fn($q) => $q->inCollection($this->filterCollection))
|
||||
->when($this->search, fn($q) => $q->where('filename', 'like', "%{$this->search}%"))
|
||||
->orderByDesc('created_at')
|
||||
->paginate(48),
|
||||
);
|
||||
|
||||
$collections = computed(fn() => CmsMedia::query()->whereNotNull('collection')->where('collection', '!=', '')->distinct()->pluck('collection')->sort()->values()->toArray());
|
||||
|
||||
$profiles = computed(fn() => config('flux-cms.media.profiles', []));
|
||||
|
||||
$stats = computed(
|
||||
fn() => [
|
||||
'total' => CmsMedia::count(),
|
||||
'images' => CmsMedia::images()->count(),
|
||||
'pdfs' => CmsMedia::pdfs()->count(),
|
||||
],
|
||||
);
|
||||
|
||||
on([
|
||||
'media-library-uploaded' => function ($mediaId) {
|
||||
$media = CmsMedia::find($mediaId);
|
||||
if ($media) {
|
||||
Flux::toast(variant: 'success', heading: 'Hochgeladen', text: $media->filename . ' wurde erfolgreich hochgeladen.');
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
$startEdit = function (int $id) {
|
||||
$media = CmsMedia::find($id);
|
||||
if (!$media) {
|
||||
return;
|
||||
}
|
||||
$this->editingId = $id;
|
||||
$this->altText = $media->getTranslation('alt_text', $this->editLocale) ?? '';
|
||||
$this->mediaTitle = $media->getTranslation('title', $this->editLocale) ?? '';
|
||||
$this->collection = $media->collection ?? '';
|
||||
$this->showDetail = true;
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$media = CmsMedia::find($this->editingId);
|
||||
if ($media) {
|
||||
$this->altText = $media->getTranslation('alt_text', $locale) ?? '';
|
||||
$this->mediaTitle = $media->getTranslation('title', $locale) ?? '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$saveEdit = function () {
|
||||
$media = CmsMedia::find($this->editingId);
|
||||
if (!$media) {
|
||||
return;
|
||||
}
|
||||
|
||||
$media->setTranslation('alt_text', $this->editLocale, $this->altText);
|
||||
$media->setTranslation('title', $this->editLocale, $this->mediaTitle);
|
||||
$media->collection = $this->collection;
|
||||
$media->save();
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Medien-Details wurden aktualisiert.');
|
||||
};
|
||||
|
||||
$generateConversion = function (string $profile) {
|
||||
$media = CmsMedia::find($this->editingId);
|
||||
if (!$media || !$media->isImage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
$result = $service->convert($media, $profile);
|
||||
|
||||
if ($result) {
|
||||
$media->refresh();
|
||||
Flux::toast(variant: 'success', heading: 'Conversion erstellt', text: "Profil \"{$profile}\" wurde generiert.");
|
||||
} else {
|
||||
Flux::toast(variant: 'danger', heading: 'Fehler', text: "Conversion \"{$profile}\" konnte nicht erstellt werden.");
|
||||
}
|
||||
};
|
||||
|
||||
$generateAllConversions = function () {
|
||||
$media = CmsMedia::find($this->editingId);
|
||||
if (!$media || !$media->isImage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service = app(MediaConversionService::class);
|
||||
$results = $service->generateAllConversions($media);
|
||||
|
||||
$count = count(array_filter($results));
|
||||
$media->refresh();
|
||||
Flux::toast(variant: 'success', heading: 'Alle Conversions erstellt', text: "{$count} Profile wurden generiert.");
|
||||
};
|
||||
|
||||
$deleteMedia = function (int $id) {
|
||||
$media = CmsMedia::find($id);
|
||||
if (!$media) {
|
||||
return;
|
||||
}
|
||||
|
||||
$filename = $media->filename;
|
||||
$service = app(MediaConversionService::class);
|
||||
$service->deleteAll($media);
|
||||
$media->delete();
|
||||
|
||||
$this->editingId = null;
|
||||
$this->showDetail = false;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: "{$filename} wurde entfernt.");
|
||||
};
|
||||
|
||||
$closeDetail = function () {
|
||||
$this->showDetail = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Medienbibliothek</flux:heading>
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:badge color="blue">{{ $this->stats['images'] }} Bilder</flux:badge>
|
||||
<flux:badge color="amber">{{ $this->stats['pdfs'] }} PDFs</flux:badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Upload Area --}}
|
||||
<flux:card class="mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<livewire:admin.cms.media-library-uploader key="media-lib-uploader" />
|
||||
</div>
|
||||
</div>
|
||||
</flux:card>
|
||||
|
||||
{{-- Filters --}}
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Dateiname suchen..." icon="magnifying-glass"
|
||||
size="sm" class="w-56" />
|
||||
|
||||
<flux:select wire:model.live="filterType" size="sm" class="w-36">
|
||||
<flux:select.option value="all">Alle Typen</flux:select.option>
|
||||
<flux:select.option value="image">Bilder</flux:select.option>
|
||||
<flux:select.option value="pdf">PDFs</flux:select.option>
|
||||
<flux:select.option value="document">Dokumente</flux:select.option>
|
||||
</flux:select>
|
||||
|
||||
@if (!empty($this->collections))
|
||||
<flux:select wire:model.live="filterCollection" size="sm" class="w-40">
|
||||
<flux:select.option value="">Alle Ordner</flux:select.option>
|
||||
@foreach ($this->collections as $col)
|
||||
<flux:select.option value="{{ $col }}">{{ $col }}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
@endif
|
||||
|
||||
<div class="ml-auto flex items-center gap-1 rounded-lg border border-zinc-200 p-0.5 dark:border-zinc-700">
|
||||
<button wire:click="$set('viewMode', 'grid')"
|
||||
class="rounded-md p-1.5 transition {{ $viewMode === 'grid' ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-600 dark:text-white' : 'text-zinc-400 hover:text-zinc-600' }}">
|
||||
<x-heroicon-s-squares-2x2 class="h-4 w-4" />
|
||||
</button>
|
||||
<button wire:click="$set('viewMode', 'list')"
|
||||
class="rounded-md p-1.5 transition {{ $viewMode === 'list' ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-600 dark:text-white' : 'text-zinc-400 hover:text-zinc-600' }}">
|
||||
<x-heroicon-s-list-bullet class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 {{ $showDetail ? 'lg:grid-cols-3' : '' }}">
|
||||
{{-- Media Grid / List --}}
|
||||
<div class="{{ $showDetail ? 'lg:col-span-2' : '' }}">
|
||||
@if ($viewMode === 'grid')
|
||||
<div
|
||||
class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 {{ $showDetail ? 'lg:grid-cols-3' : 'lg:grid-cols-6' }}">
|
||||
@forelse ($this->media as $item)
|
||||
<div wire:key="media-g-{{ $item->id }}"
|
||||
class="group relative cursor-pointer overflow-hidden rounded-lg border transition-all
|
||||
{{ $editingId === $item->id ? 'border-blue-500 ring-2 ring-blue-200 dark:ring-blue-800' : 'border-zinc-200 hover:border-zinc-400 dark:border-zinc-700' }}"
|
||||
wire:click="startEdit({{ $item->id }})">
|
||||
<div class="aspect-square bg-zinc-100 dark:bg-zinc-800">
|
||||
@if ($item->isImage())
|
||||
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
|
||||
alt="{{ $item->getTranslation('alt_text', $editLocale) ?? $item->filename }}"
|
||||
class="h-full w-full object-cover" loading="lazy" />
|
||||
@elseif ($item->isPdf())
|
||||
<div class="relative h-full w-full">
|
||||
<iframe src="{{ $item->getUrl() }}#toolbar=0&navpanes=0&scrollbar=0&view=FitH"
|
||||
class="pointer-events-none h-full w-full scale-100 bg-white"
|
||||
loading="lazy"></iframe>
|
||||
<div class="absolute inset-0"></div>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 text-zinc-400">
|
||||
<x-heroicon-o-document class="h-10 w-10" />
|
||||
<span
|
||||
class="text-xs">{{ strtoupper(pathinfo($item->filename, PATHINFO_EXTENSION)) }}</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 p-2">
|
||||
@if ($item->isImage())
|
||||
<x-heroicon-s-photo class="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||
@elseif ($item->isPdf())
|
||||
<x-heroicon-s-document-text class="h-3.5 w-3.5 shrink-0 text-red-500" />
|
||||
@else
|
||||
<x-heroicon-s-document class="h-3.5 w-3.5 shrink-0 text-zinc-400" />
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{{ $item->filename }}</p>
|
||||
<p class="text-xs text-zinc-400">{{ $item->getHumanFileSize() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@if ($item->collection)
|
||||
<div class="absolute right-1 top-1">
|
||||
<flux:badge size="sm" color="blue" class="text-[10px]!">
|
||||
{{ $item->collection }}</flux:badge>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@empty
|
||||
<div class="col-span-full py-12 text-center">
|
||||
<x-heroicon-o-photo class="mx-auto mb-3 h-12 w-12 text-zinc-300" />
|
||||
<flux:text>Noch keine Medien hochgeladen.</flux:text>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
@else
|
||||
{{-- List View --}}
|
||||
<div class="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead
|
||||
class="border-b border-zinc-200 bg-zinc-50 text-left text-xs text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
<tr>
|
||||
<th class="w-12 px-3 py-2"></th>
|
||||
<th class="px-3 py-2">Dateiname</th>
|
||||
<th class="hidden px-3 py-2 sm:table-cell">Typ</th>
|
||||
<th class="hidden px-3 py-2 md:table-cell">Größe</th>
|
||||
<th class="hidden px-3 py-2 md:table-cell">Abmessungen</th>
|
||||
<th class="hidden px-3 py-2 lg:table-cell">Sammlung</th>
|
||||
<th class="px-3 py-2 text-right">Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-100 dark:divide-zinc-800">
|
||||
@forelse ($this->media as $item)
|
||||
<tr wire:key="media-l-{{ $item->id }}"
|
||||
class="cursor-pointer transition {{ $editingId === $item->id ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50' }}"
|
||||
wire:click="startEdit({{ $item->id }})">
|
||||
<td class="px-3 py-1.5">
|
||||
<div
|
||||
class="h-8 w-8 overflow-hidden rounded border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
@if ($item->isImage())
|
||||
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
|
||||
class="h-full w-full object-cover" loading="lazy" />
|
||||
@elseif ($item->isPdf())
|
||||
<div class="relative h-full w-full">
|
||||
<iframe
|
||||
src="{{ $item->getUrl() }}#toolbar=0&navpanes=0&scrollbar=0&view=FitH"
|
||||
class="pointer-events-none h-full w-full bg-white"
|
||||
loading="lazy"></iframe>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center text-zinc-400">
|
||||
<x-heroicon-s-document class="h-4 w-4" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-1.5">
|
||||
<span
|
||||
class="font-medium text-zinc-700 dark:text-zinc-300">{{ $item->filename }}</span>
|
||||
</td>
|
||||
<td class="hidden px-3 py-1.5 text-zinc-500 sm:table-cell">
|
||||
<flux:badge size="sm"
|
||||
:color="$item->isImage() ? 'blue' : ($item->isPdf() ? 'amber' : 'zinc')">
|
||||
{{ $item->type }}
|
||||
</flux:badge>
|
||||
</td>
|
||||
<td class="hidden px-3 py-1.5 text-zinc-500 md:table-cell">
|
||||
{{ $item->getHumanFileSize() }}</td>
|
||||
<td class="hidden px-3 py-1.5 text-zinc-500 md:table-cell">
|
||||
{{ $item->getDimensionsLabel() ?: '—' }}</td>
|
||||
<td class="hidden px-3 py-1.5 lg:table-cell">
|
||||
@if ($item->collection)
|
||||
<flux:badge size="sm" color="blue">{{ $item->collection }}
|
||||
</flux:badge>
|
||||
@else
|
||||
<span class="text-zinc-300">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right text-zinc-400">
|
||||
{{ $item->created_at->format('d.m.Y') }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="py-12 text-center">
|
||||
<x-heroicon-o-photo class="mx-auto mb-3 h-12 w-12 text-zinc-300" />
|
||||
<flux:text>Noch keine Medien hochgeladen.</flux:text>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($this->media->hasPages())
|
||||
<div class="mt-4">
|
||||
{{ $this->media->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Detail Sidebar --}}
|
||||
@if ($showDetail && $editingId)
|
||||
@php $editMedia = \FluxCms\Core\Models\CmsMedia::find($editingId); @endphp
|
||||
@if ($editMedia)
|
||||
<div class="lg:col-span-1">
|
||||
<flux:card>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<flux:heading size="sm">Details</flux:heading>
|
||||
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="closeDetail" />
|
||||
</div>
|
||||
|
||||
{{-- Large Preview --}}
|
||||
<div
|
||||
class="mb-4 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
@if ($editMedia->isImage())
|
||||
<img src="{{ $editMedia->getUrl() }}" alt="{{ $editMedia->filename }}"
|
||||
class="w-full object-contain" style="max-height: 300px;" />
|
||||
@elseif ($editMedia->isPdf())
|
||||
<iframe src="{{ $editMedia->getUrl() }}#toolbar=0&navpanes=0"
|
||||
class="h-64 w-full bg-white" loading="lazy"></iframe>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- File Info --}}
|
||||
<div class="mb-4 space-y-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<p><strong>Datei:</strong> {{ $editMedia->filename }}</p>
|
||||
<p><strong>Typ:</strong> {{ $editMedia->mime_type }}</p>
|
||||
<p><strong>Größe:</strong> {{ $editMedia->getHumanFileSize() }}</p>
|
||||
@if ($editMedia->getDimensionsLabel())
|
||||
<p><strong>Abmessungen:</strong> {{ $editMedia->getDimensionsLabel() }} px</p>
|
||||
@endif
|
||||
<p><strong>Hochgeladen:</strong> {{ $editMedia->created_at->format('d.m.Y H:i') }}</p>
|
||||
<p class="break-all"><strong>URL:</strong>
|
||||
<a href="{{ $editMedia->getUrl() }}" target="_blank"
|
||||
class="text-blue-500 hover:underline">
|
||||
{{ $editMedia->getUrl() }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Locale Switcher --}}
|
||||
<div class="mb-3 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>
|
||||
|
||||
{{-- Edit Fields --}}
|
||||
<div class="space-y-3">
|
||||
<flux:input wire:model="mediaTitle" label="Titel" size="sm"
|
||||
placeholder="Anzeigename..." />
|
||||
<flux:input wire:model="altText" label="Alt-Text" size="sm"
|
||||
placeholder="Bildbeschreibung für SEO..." />
|
||||
<flux:input wire:model="collection" label="Ordner / Sammlung" size="sm"
|
||||
placeholder="z.B. hero, team, news..." />
|
||||
|
||||
<flux:button size="sm" variant="primary" wire:click="saveEdit" class="w-full">
|
||||
Speichern
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Conversions --}}
|
||||
@if ($editMedia->isImage() && $editMedia->mime_type !== 'image/svg+xml')
|
||||
<div class="mt-5 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<flux:heading size="sm">Bildgrößen</flux:heading>
|
||||
<flux:button size="xs" variant="ghost" wire:click="generateAllConversions"
|
||||
wire:loading.attr="disabled">
|
||||
Alle generieren
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($this->profiles as $profileName => $profileConfig)
|
||||
@php
|
||||
$hasConversion = $editMedia->hasConversion($profileName);
|
||||
$conversionUrl = $hasConversion
|
||||
? $editMedia->getConversionUrl($profileName)
|
||||
: null;
|
||||
@endphp
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border border-zinc-200 px-3 py-2 dark:border-zinc-700">
|
||||
<div>
|
||||
<span class="text-sm font-medium">{{ $profileName }}</span>
|
||||
<span class="text-xs text-zinc-400">
|
||||
{{ $profileConfig['width'] }}×{{ $profileConfig['height'] }}
|
||||
{{ strtoupper($profileConfig['format'] ?? 'webp') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($hasConversion)
|
||||
<flux:badge size="sm" color="green">OK</flux:badge>
|
||||
@else
|
||||
<flux:badge size="sm" color="zinc">—</flux:badge>
|
||||
@endif
|
||||
<flux:button size="xs" variant="ghost" icon="arrow-path"
|
||||
wire:click="generateConversion('{{ $profileName }}')"
|
||||
wire:loading.attr="disabled" />
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Delete --}}
|
||||
<div class="mt-5 border-t border-zinc-200 pt-4 dark:border-zinc-700">
|
||||
<flux:button size="sm" variant="danger" class="w-full" icon="trash"
|
||||
wire:click="deleteMedia({{ $editMedia->id }})"
|
||||
wire:confirm="'{{ $editMedia->filename }}' wirklich löschen? Alle Conversions werden ebenfalls entfernt.">
|
||||
Datei löschen
|
||||
</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<div>
|
||||
<flux:file-upload wire:model="uploads" multiple
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png">
|
||||
<flux:file-upload.dropzone
|
||||
heading="Dateien hochladen"
|
||||
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 10 MB pro Datei"
|
||||
with-progress />
|
||||
</flux:file-upload>
|
||||
|
||||
@if (isset($uploads) && count($uploads) > 0)
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
@foreach ($uploads as $index => $upload)
|
||||
<flux:file-item
|
||||
:heading="$upload->getClientOriginalName()"
|
||||
:image="(str_starts_with($upload->getMimeType() ?? '', 'image/') && $upload->isPreviewable())
|
||||
? $upload->temporaryUrl()
|
||||
: null"
|
||||
:size="$upload->getSize()">
|
||||
<x-slot name="actions">
|
||||
<flux:file-item.remove
|
||||
wire:click="removeUpload({{ $index }})"
|
||||
aria-label="Entfernen: {{ $upload->getClientOriginalName() }}" />
|
||||
</x-slot>
|
||||
</flux:file-item>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:error name="uploads" />
|
||||
</div>
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
<div>
|
||||
{{-- Current Selection Preview --}}
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
@if ($this->selected)
|
||||
<div class="flex items-center gap-3 rounded-lg border border-zinc-200 p-2 dark:border-zinc-700">
|
||||
@if ($this->selected->isImage())
|
||||
<img src="{{ $this->selected->hasConversion('thumb') ? $this->selected->getConversionUrl('thumb') : $this->selected->getUrl() }}"
|
||||
alt="{{ $this->selected->filename }}"
|
||||
class="h-16 w-16 rounded-md object-cover" />
|
||||
@elseif ($this->selected->isPdf())
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<x-heroicon-o-document-text class="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{{ $this->selected->filename }}
|
||||
</p>
|
||||
<p class="text-xs text-zinc-400">
|
||||
{{ $this->selected->getHumanFileSize() }}
|
||||
@if ($this->selected->getDimensionsLabel())
|
||||
— {{ $this->selected->getDimensionsLabel() }}
|
||||
@endif
|
||||
</p>
|
||||
@if ($this->selected->isImage() && $this->selected->hasConversion($profile))
|
||||
@php
|
||||
$pConfig = config("flux-cms.media.profiles.{$profile}", []);
|
||||
@endphp
|
||||
<flux:badge size="sm" color="green" class="mt-1">
|
||||
{{ $profile }}: {{ $pConfig['width'] ?? '?' }}×{{ $pConfig['height'] ?? '?' }}
|
||||
</flux:badge>
|
||||
@endif
|
||||
</div>
|
||||
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="clearSelection" />
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-lg border-2 border-dashed border-zinc-300 px-4 py-3 text-center text-sm text-zinc-400 dark:border-zinc-600">
|
||||
Kein Medium ausgewählt
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<flux:button size="sm" variant="ghost" icon="photo" wire:click="openPicker">
|
||||
{{ $label }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
{{-- Picker Modal --}}
|
||||
<flux:modal wire:model="showModal" class="w-full max-w-4xl space-y-4 overflow-y-auto max-h-[85vh]">
|
||||
<flux:heading size="lg">{{ $label }}</flux:heading>
|
||||
|
||||
{{-- Quick Upload + Search --}}
|
||||
<div class="flex flex-col gap-3">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." icon="magnifying-glass"
|
||||
size="sm" />
|
||||
|
||||
<flux:file-upload wire:model="quickUploads" multiple
|
||||
accept="{{ $type === 'image' ? 'image/jpeg,image/png,image/gif,image/webp,.jpg,.jpeg,.png' : ($type === 'pdf' ? '.pdf,application/pdf' : '*') }}">
|
||||
<flux:file-upload.dropzone
|
||||
heading="Neue Datei hochladen"
|
||||
text="Direkt hier hochladen und zuweisen"
|
||||
with-progress />
|
||||
</flux:file-upload>
|
||||
|
||||
@if (isset($quickUploads) && count($quickUploads) > 0)
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@foreach ($quickUploads as $index => $upload)
|
||||
<flux:file-item
|
||||
:heading="$upload->getClientOriginalName()"
|
||||
:image="(str_starts_with($upload->getMimeType() ?? '', 'image/') && $upload->isPreviewable())
|
||||
? $upload->temporaryUrl()
|
||||
: null"
|
||||
:size="$upload->getSize()">
|
||||
<x-slot name="actions">
|
||||
<flux:file-item.remove wire:click="removeQuickUpload({{ $index }})" />
|
||||
</x-slot>
|
||||
</flux:file-item>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:error name="quickUploads" />
|
||||
</div>
|
||||
|
||||
@php $profileConfig = config("flux-cms.media.profiles.{$profile}", []); @endphp
|
||||
@if (!empty($profileConfig))
|
||||
<flux:text class="text-xs">
|
||||
Profil <strong>{{ $profile }}</strong>: {{ $profileConfig['width'] }}×{{ $profileConfig['height'] }} px,
|
||||
{{ strtoupper($profileConfig['format'] ?? 'webp') }},
|
||||
Qualität {{ $profileConfig['quality'] ?? 85 }}%
|
||||
</flux:text>
|
||||
@endif
|
||||
|
||||
{{-- Media Grid --}}
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
|
||||
@forelse ($this->mediaItems as $item)
|
||||
<div wire:key="pick-{{ $item->id }}"
|
||||
class="group cursor-pointer overflow-hidden rounded-lg border transition-all
|
||||
{{ $value === $item->id ? 'border-blue-500 ring-2 ring-blue-200' : 'border-zinc-200 hover:border-blue-300 dark:border-zinc-700' }}"
|
||||
wire:click="selectMedia({{ $item->id }})">
|
||||
<div class="aspect-square bg-zinc-100 dark:bg-zinc-800">
|
||||
@if ($item->isImage())
|
||||
<img src="{{ $item->hasConversion('thumb') ? $item->getConversionUrl('thumb') : $item->getUrl() }}"
|
||||
alt="{{ $item->filename }}" class="h-full w-full object-cover" loading="lazy" />
|
||||
@elseif ($item->isPdf())
|
||||
<div class="flex h-full w-full items-center justify-center text-red-500">
|
||||
<x-heroicon-o-document-text class="h-8 w-8" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-1.5">
|
||||
<p class="truncate text-[11px] text-zinc-600 dark:text-zinc-400">{{ $item->filename }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="col-span-full py-8 text-center">
|
||||
<flux:text>Keine Medien gefunden.</flux:text>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
@if ($this->mediaItems->hasPages())
|
||||
<div class="mt-2">
|
||||
{{ $this->mediaItems->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<div class="inline-flex items-center gap-2">
|
||||
<input type="file" wire:model="file" accept="{{ $accept }}"
|
||||
class="text-sm file:mr-2 file:rounded-md file:border-0 file:bg-zinc-100 file:px-3 file:py-1.5 file:text-sm file:font-medium hover:file:bg-zinc-200 dark:file:bg-zinc-700 dark:hover:file:bg-zinc-600" />
|
||||
|
||||
<div wire:loading wire:target="file">
|
||||
<flux:icon name="arrow-path" class="h-4 w-4 animate-spin text-zinc-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
<?php
|
||||
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsNewsItem;
|
||||
use FluxCms\Core\Services\HeroiconOutlineList;
|
||||
use function Livewire\Volt\{state, computed, layout, on};
|
||||
|
||||
layout('components.layouts.cms');
|
||||
|
||||
state([
|
||||
'editLocale' => 'de',
|
||||
'showForm' => false,
|
||||
'editingId' => null,
|
||||
'icon' => '',
|
||||
'text' => '',
|
||||
'title' => '',
|
||||
'excerpt' => '',
|
||||
'content' => '',
|
||||
'image' => '',
|
||||
'imageMediaId' => null,
|
||||
'pdfMediaId' => null,
|
||||
'date' => null,
|
||||
'author' => '',
|
||||
'link' => '',
|
||||
'pdf_path' => '',
|
||||
'pdf_open_text' => '',
|
||||
'pdf_download_text' => '',
|
||||
]);
|
||||
|
||||
$items = computed(fn() => CmsNewsItem::ordered()->get());
|
||||
|
||||
$availableIcons = computed(fn () => HeroiconOutlineList::names());
|
||||
|
||||
$create = function () {
|
||||
$this->reset(['editingId', 'icon', 'text', 'title', 'excerpt', 'content', 'image', 'imageMediaId', 'pdfMediaId', 'date', 'author', 'link', 'pdf_path', 'pdf_open_text', 'pdf_download_text']);
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$edit = function (int $id) {
|
||||
$item = CmsNewsItem::findOrFail($id);
|
||||
$this->editingId = $id;
|
||||
$this->showForm = true;
|
||||
$l = $this->editLocale;
|
||||
$this->icon = $item->icon ?? '';
|
||||
$this->text = $item->getTranslation('text', $l) ?? '';
|
||||
$this->title = $item->getTranslation('title', $l) ?? '';
|
||||
$this->excerpt = $item->getTranslation('excerpt', $l) ?? '';
|
||||
$this->content = $item->getTranslation('content', $l) ?? '';
|
||||
$this->image = $item->image ?? '';
|
||||
$this->date = $item->date?->format('Y-m-d');
|
||||
$this->author = $item->author ?? '';
|
||||
$this->link = $item->link ?? '';
|
||||
$this->pdf_path = $item->pdf_path ?? '';
|
||||
$this->pdf_open_text = $item->getTranslation('pdf_open_text', $l) ?? '';
|
||||
$this->pdf_download_text = $item->getTranslation('pdf_download_text', $l) ?? '';
|
||||
$this->imageMediaId = $this->image ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->image)->first()?->id : null;
|
||||
$this->pdfMediaId = $this->pdf_path ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->pdf_path)->first()?->id : null;
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$existing = $this->editingId ? CmsNewsItem::find($this->editingId) : null;
|
||||
$merge = function (string $field, string $value) use ($existing) {
|
||||
$t = $existing ? $existing->getTranslations($field) : [];
|
||||
$t[$this->editLocale] = $value;
|
||||
return $t;
|
||||
};
|
||||
|
||||
$data = [
|
||||
'icon' => $this->icon,
|
||||
'text' => $merge('text', $this->text),
|
||||
'title' => $merge('title', $this->title),
|
||||
'excerpt' => $merge('excerpt', $this->excerpt),
|
||||
'content' => $merge('content', $this->content),
|
||||
'image' => $this->image,
|
||||
'date' => $this->date ?: null,
|
||||
'author' => $this->author,
|
||||
'link' => $this->link,
|
||||
'pdf_path' => $this->pdf_path,
|
||||
'pdf_open_text' => $merge('pdf_open_text', $this->pdf_open_text),
|
||||
'pdf_download_text' => $merge('pdf_download_text', $this->pdf_download_text),
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($data);
|
||||
} else {
|
||||
$data['order'] = CmsNewsItem::max('order') + 1;
|
||||
$data['is_published'] = true;
|
||||
CmsNewsItem::create($data);
|
||||
}
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'News-Eintrag wurde erfolgreich gespeichert.');
|
||||
};
|
||||
|
||||
$delete = function (int $id) {
|
||||
CmsNewsItem::findOrFail($id)->delete();
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'News-Eintrag wurde entfernt.');
|
||||
};
|
||||
|
||||
$togglePublished = function (int $id) {
|
||||
$item = CmsNewsItem::findOrFail($id);
|
||||
$item->update(['is_published' => !$item->is_published]);
|
||||
Flux::toast(heading: 'Status geändert', text: $item->is_published ? 'Veröffentlicht' : 'Entwurf');
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingId) {
|
||||
$item = CmsNewsItem::find($this->editingId);
|
||||
if ($item) {
|
||||
$this->text = $item->getTranslation('text', $locale) ?? '';
|
||||
$this->title = $item->getTranslation('title', $locale) ?? '';
|
||||
$this->excerpt = $item->getTranslation('excerpt', $locale) ?? '';
|
||||
$this->content = $item->getTranslation('content', $locale) ?? '';
|
||||
$this->pdf_open_text = $item->getTranslation('pdf_open_text', $locale) ?? '';
|
||||
$this->pdf_download_text = $item->getTranslation('pdf_download_text', $locale) ?? '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$cancel = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingId = null;
|
||||
};
|
||||
|
||||
on([
|
||||
'media-selected' => function (string $field, ?int $mediaId, ?string $url) {
|
||||
if ($field === 'news_image') {
|
||||
$this->imageMediaId = $mediaId;
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
$this->image = $media ? $media->filename : '';
|
||||
} elseif ($field === 'news_pdf') {
|
||||
$this->pdfMediaId = $mediaId;
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
$this->pdf_path = $media ? $media->filename : '';
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">News Band</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="create">Neu</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($showForm)
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="lg" class="mb-4">{{ $editingId ? 'Bearbeiten' : 'Neuer News-Eintrag' }}
|
||||
</flux:heading>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:input wire:model="title" label="Titel" />
|
||||
<flux:input wire:model="text" label="Band-Text (kurz)" />
|
||||
<div>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<flux:select wire:model="icon" variant="listbox" searchable label="Icon"
|
||||
placeholder="Icon auswählen...">
|
||||
@foreach ($this->availableIcons as $iconName)
|
||||
<flux:select.option value="{{ $iconName }}">{{ $iconName }}
|
||||
</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
@if ($icon)
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<x-dynamic-component :component="'heroicon-o-' . $icon" class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<flux:input wire:model="author" label="Autor" />
|
||||
<flux:input wire:model="date" label="Datum" type="date" />
|
||||
<flux:input wire:model="link" label="Link (optional)" />
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Bild</label>
|
||||
<div class="flex items-start gap-3">
|
||||
@if ($image)
|
||||
<div
|
||||
class="h-16 w-16 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="$imageMediaId" field="news_image" type="image"
|
||||
profile="news" label="Bild wählen" :key="'news-img-' . ($editingId ?? 'new')" />
|
||||
<p class="mt-1 text-xs text-zinc-400">{{ $image ?: 'Kein Bild' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">PDF-Dokument</label>
|
||||
@if ($pdf_path)
|
||||
<div class="mb-2 overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<iframe src="{{ media_url($pdf_path) }}#toolbar=0&navpanes=0" class="h-48 w-full bg-white"
|
||||
loading="lazy"></iframe>
|
||||
</div>
|
||||
<p class="mb-1 text-xs text-zinc-400">{{ $pdf_path }}</p>
|
||||
@endif
|
||||
<livewire:admin.cms.media-picker :value="$pdfMediaId" field="news_pdf" type="pdf" profile=""
|
||||
label="PDF wählen" :key="'news-pdf-' . ($editingId ?? 'new')" />
|
||||
</div>
|
||||
<flux:input wire:model="pdf_open_text" label="PDF öffnen Text" />
|
||||
<flux:input wire:model="pdf_download_text" label="PDF Download Text" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<flux:editor wire:model="excerpt" label="Kurztext" toolbar="bold italic"
|
||||
class="**:data-[slot=content]:min-h-[60px]!" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<flux:editor wire:model="content" label="Inhalt"
|
||||
toolbar="heading | bold italic underline strike | bullet ordered blockquote | link"
|
||||
class="**:data-[slot=content]:min-h-[120px]!" />
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->items as $item)
|
||||
<div wire:key="news-{{ $item->id }}" class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
@if ($item->image)
|
||||
<div class="h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
|
||||
<img src="{{ media_url($item->image) }}" alt=""
|
||||
class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
@if ($item->icon)
|
||||
<x-dynamic-component :component="'heroicon-o-' . $item->icon" class="h-5 w-5 shrink-0 text-primary" />
|
||||
@endif
|
||||
<span class="font-medium">{{ $item->getTranslation('title', $editLocale) }}</span>
|
||||
@unless ($item->is_published)
|
||||
<flux:badge size="sm" color="amber">Entwurf</flux:badge>
|
||||
@endunless
|
||||
</div>
|
||||
<p class="truncate text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ $item->getTranslation('text', $editLocale) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="edit({{ $item->id }})" />
|
||||
<flux:button size="sm" variant="ghost" :icon="$item->is_published ? 'eye' : 'eye-slash'"
|
||||
wire:click="togglePublished({{ $item->id }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="delete({{ $item->id }})" wire:confirm="Wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine News-Einträge vorhanden.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
<?php
|
||||
|
||||
use function Livewire\Volt\{layout};
|
||||
layout('components.layouts.cms');
|
||||
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsSearchIndex;
|
||||
use function Livewire\Volt\{state, computed, on};
|
||||
|
||||
state([
|
||||
'search' => '',
|
||||
'editingId' => null,
|
||||
'editLocale' => 'de',
|
||||
'itemId' => '',
|
||||
'route' => '',
|
||||
'routeParams' => '',
|
||||
'category' => '',
|
||||
'titleKey' => '',
|
||||
'titleFallback' => '',
|
||||
'descriptionKey' => '',
|
||||
'descriptionFallbackKey' => '',
|
||||
'descriptionFallbackText' => '',
|
||||
'keywords' => [],
|
||||
'newKeyword' => '',
|
||||
'isPublished' => true,
|
||||
'reindexing' => false,
|
||||
]);
|
||||
|
||||
$items = computed(
|
||||
fn () => CmsSearchIndex::query()
|
||||
->when($this->search, fn ($q) => $q->where('item_id', 'like', "%{$this->search}%")
|
||||
->orWhere('route', 'like', "%{$this->search}%")
|
||||
->orWhere('category', 'like', "%{$this->search}%"))
|
||||
->ordered()
|
||||
->get()
|
||||
);
|
||||
|
||||
$startEdit = function (int $id) {
|
||||
$item = CmsSearchIndex::find($id);
|
||||
if (! $item) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->editingId = $id;
|
||||
$this->itemId = $item->item_id;
|
||||
$this->route = $item->route;
|
||||
$this->routeParams = implode(', ', $item->route_params ?? []);
|
||||
$this->category = $item->getTranslation('category', $this->editLocale, false) ?? '';
|
||||
$this->titleKey = $item->title_key ?? '';
|
||||
$this->titleFallback = $item->getTranslation('title_fallback', $this->editLocale, false) ?? '';
|
||||
$this->descriptionKey = $item->description_key ?? '';
|
||||
$this->descriptionFallbackKey = $item->description_fallback_key ?? '';
|
||||
$this->descriptionFallbackText = $item->getTranslation('description_fallback_text', $this->editLocale, false) ?? '';
|
||||
$this->keywords = $item->getTranslation('keywords', $this->editLocale, false) ?? [];
|
||||
if (! is_array($this->keywords)) {
|
||||
$this->keywords = [];
|
||||
}
|
||||
$this->isPublished = $item->is_published;
|
||||
$this->newKeyword = '';
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
if ($this->editingId) {
|
||||
$item = CmsSearchIndex::find($this->editingId);
|
||||
if ($item) {
|
||||
$this->editLocale = $locale;
|
||||
$this->category = $item->getTranslation('category', $locale, false) ?? '';
|
||||
$this->titleFallback = $item->getTranslation('title_fallback', $locale, false) ?? '';
|
||||
$this->descriptionFallbackText = $item->getTranslation('description_fallback_text', $locale, false) ?? '';
|
||||
$this->keywords = $item->getTranslation('keywords', $locale, false) ?? [];
|
||||
if (! is_array($this->keywords)) {
|
||||
$this->keywords = [];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->editLocale = $locale;
|
||||
}
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$item = CmsSearchIndex::find($this->editingId);
|
||||
if (! $item) {
|
||||
return;
|
||||
}
|
||||
|
||||
$item->item_id = $this->itemId;
|
||||
$item->route = $this->route;
|
||||
$item->route_params = array_values(array_filter(array_map('trim', explode(',', $this->routeParams))));
|
||||
$item->setTranslation('category', $this->editLocale, $this->category);
|
||||
|
||||
$item->title_key = $this->titleKey ?: null;
|
||||
$item->setTranslation('title_fallback', $this->editLocale, $this->titleFallback ?: null);
|
||||
|
||||
$item->description_key = $this->descriptionKey ?: null;
|
||||
$item->description_fallback_key = $this->descriptionFallbackKey ?: null;
|
||||
$item->setTranslation('description_fallback_text', $this->editLocale, $this->descriptionFallbackText ?: null);
|
||||
|
||||
$cleanKeywords = array_values(array_filter($this->keywords, fn ($k) => is_string($k) && trim($k) !== ''));
|
||||
$item->setTranslation('keywords', $this->editLocale, $cleanKeywords);
|
||||
$item->is_published = $this->isPublished;
|
||||
$item->save();
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: "Suchindex-Eintrag '{$item->item_id}' wurde gespeichert.");
|
||||
};
|
||||
|
||||
$addKeyword = function () {
|
||||
$keyword = trim($this->newKeyword);
|
||||
if ($keyword !== '' && ! in_array($keyword, $this->keywords)) {
|
||||
$this->keywords[] = $keyword;
|
||||
}
|
||||
$this->newKeyword = '';
|
||||
};
|
||||
|
||||
$removeKeyword = function (int $index) {
|
||||
unset($this->keywords[$index]);
|
||||
$this->keywords = array_values($this->keywords);
|
||||
};
|
||||
|
||||
$create = function () {
|
||||
$maxOrder = CmsSearchIndex::max('order') ?? -1;
|
||||
$item = CmsSearchIndex::create([
|
||||
'item_id' => 'new-item-' . time(),
|
||||
'route' => 'home',
|
||||
'route_params' => [],
|
||||
'category' => ['de' => 'Neu', 'en' => 'New'],
|
||||
'keywords' => ['de' => [], 'en' => []],
|
||||
'is_published' => false,
|
||||
'order' => $maxOrder + 1,
|
||||
]);
|
||||
|
||||
$this->editingId = $item->id;
|
||||
$this->itemId = $item->item_id;
|
||||
$this->route = $item->route;
|
||||
$this->routeParams = '';
|
||||
$this->category = 'Neu';
|
||||
$this->titleKey = '';
|
||||
$this->titleFallback = '';
|
||||
$this->descriptionKey = '';
|
||||
$this->descriptionFallbackKey = '';
|
||||
$this->descriptionFallbackText = '';
|
||||
$this->keywords = [];
|
||||
$this->isPublished = false;
|
||||
$this->newKeyword = '';
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Erstellt', text: 'Neuer Suchindex-Eintrag wurde erstellt.');
|
||||
};
|
||||
|
||||
$delete = function (int $id) {
|
||||
$item = CmsSearchIndex::find($id);
|
||||
if ($item) {
|
||||
$name = $item->item_id;
|
||||
$item->delete();
|
||||
if ($this->editingId === $id) {
|
||||
$this->editingId = null;
|
||||
}
|
||||
Flux::toast(variant: 'success', heading: 'Geloescht', text: "Eintrag '{$name}' wurde entfernt.");
|
||||
}
|
||||
};
|
||||
|
||||
$togglePublished = function (int $id) {
|
||||
$item = CmsSearchIndex::find($id);
|
||||
if ($item) {
|
||||
$item->is_published = ! $item->is_published;
|
||||
$item->save();
|
||||
Flux::toast(variant: 'success', heading: 'Status geaendert', text: $item->is_published ? 'Aktiviert' : 'Deaktiviert');
|
||||
}
|
||||
};
|
||||
|
||||
$moveUp = function (int $id) {
|
||||
$item = CmsSearchIndex::find($id);
|
||||
if (! $item) {
|
||||
return;
|
||||
}
|
||||
$prev = CmsSearchIndex::where('order', '<', $item->order)->orderByDesc('order')->first();
|
||||
if ($prev) {
|
||||
$tmpOrder = $item->order;
|
||||
$item->order = $prev->order;
|
||||
$prev->order = $tmpOrder;
|
||||
$item->save();
|
||||
$prev->save();
|
||||
}
|
||||
};
|
||||
|
||||
$moveDown = function (int $id) {
|
||||
$item = CmsSearchIndex::find($id);
|
||||
if (! $item) {
|
||||
return;
|
||||
}
|
||||
$next = CmsSearchIndex::where('order', '>', $item->order)->orderBy('order')->first();
|
||||
if ($next) {
|
||||
$tmpOrder = $item->order;
|
||||
$item->order = $next->order;
|
||||
$next->order = $tmpOrder;
|
||||
$item->save();
|
||||
$next->save();
|
||||
}
|
||||
};
|
||||
|
||||
$reindex = function () {
|
||||
$this->reindexing = true;
|
||||
|
||||
try {
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('search:extract-keywords', [
|
||||
'--apply' => true,
|
||||
'--locale' => ['de', 'en'],
|
||||
]);
|
||||
|
||||
$deItems = [];
|
||||
$enItems = [];
|
||||
$dePath = lang_path('de/search_index.php');
|
||||
$enPath = lang_path('en/search_index.php');
|
||||
|
||||
if (file_exists($dePath)) {
|
||||
$deConfig = require $dePath;
|
||||
$deItems = collect($deConfig['items'] ?? [])->keyBy('id');
|
||||
}
|
||||
if (file_exists($enPath)) {
|
||||
$enConfig = require $enPath;
|
||||
$enItems = collect($enConfig['items'] ?? [])->keyBy('id');
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
foreach (CmsSearchIndex::all() as $entry) {
|
||||
$de = $deItems->get($entry->item_id);
|
||||
$en = $enItems->get($entry->item_id);
|
||||
|
||||
if ($de && ! empty($de['keywords'])) {
|
||||
$existing = $entry->getTranslation('keywords', 'de', false) ?? [];
|
||||
$merged = array_values(array_unique(array_merge(
|
||||
is_array($existing) ? $existing : [],
|
||||
$de['keywords']
|
||||
)));
|
||||
$entry->setTranslation('keywords', 'de', $merged);
|
||||
}
|
||||
|
||||
if ($en && ! empty($en['keywords'])) {
|
||||
$existing = $entry->getTranslation('keywords', 'en', false) ?? [];
|
||||
$merged = array_values(array_unique(array_merge(
|
||||
is_array($existing) ? $existing : [],
|
||||
$en['keywords']
|
||||
)));
|
||||
$entry->setTranslation('keywords', 'en', $merged);
|
||||
}
|
||||
|
||||
if ($entry->isDirty()) {
|
||||
$entry->save();
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Reindexierung abgeschlossen', text: "{$updated} Eintraege aktualisiert.");
|
||||
} catch (\Exception $e) {
|
||||
Flux::toast(variant: 'danger', heading: 'Fehler', text: $e->getMessage());
|
||||
}
|
||||
|
||||
$this->reindexing = false;
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="xl">Suchindex</flux:heading>
|
||||
<flux:text class="mt-1">Verwalte die Seiten-Suche: Keywords, Kategorien und Beschreibungen.</flux:text>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<flux:button wire:click="reindex" variant="ghost" icon="arrow-path" wire:loading.attr="disabled"
|
||||
wire:target="reindex">
|
||||
<span wire:loading.remove wire:target="reindex">Reindexieren</span>
|
||||
<span wire:loading wire:target="reindex">Wird reindexiert...</span>
|
||||
</flux:button>
|
||||
<flux:button wire:click="create" variant="primary" icon="plus">Neuer Eintrag</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suche nach ID, Route oder Kategorie..."
|
||||
icon="magnifying-glass" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
{{-- Liste --}}
|
||||
<div class="w-80 shrink-0 space-y-1 overflow-y-auto" style="max-height: 80vh;">
|
||||
@foreach ($this->items as $item)
|
||||
<div wire:key="si-{{ $item->id }}"
|
||||
class="group flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 transition
|
||||
{{ $editingId === $item->id ? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950' : 'border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600' }}
|
||||
{{ ! $item->is_published ? 'opacity-50' : '' }}"
|
||||
wire:click="startEdit({{ $item->id }})">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{{ $item->item_id }}</p>
|
||||
<p class="truncate text-xs text-zinc-400">
|
||||
{{ $item->getTranslation('category', 'de', false) }}
|
||||
· {{ $item->route }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<flux:button size="xs" variant="ghost" icon="chevron-up"
|
||||
wire:click.stop="moveUp({{ $item->id }})" class="opacity-0 group-hover:opacity-100" />
|
||||
<flux:button size="xs" variant="ghost" icon="chevron-down"
|
||||
wire:click.stop="moveDown({{ $item->id }})" class="opacity-0 group-hover:opacity-100" />
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Editor --}}
|
||||
<div class="flex-1">
|
||||
@if ($editingId)
|
||||
@php $currentItem = CmsSearchIndex::find($editingId); @endphp
|
||||
@if ($currentItem)
|
||||
<div class="rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<flux:heading size="lg">{{ $currentItem->item_id }}</flux:heading>
|
||||
<div class="flex items-center gap-3">
|
||||
<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>
|
||||
<flux:button size="sm" variant="{{ $isPublished ? 'primary' : 'ghost' }}"
|
||||
wire:click="togglePublished({{ $editingId }})">
|
||||
{{ $isPublished ? 'Aktiv' : 'Inaktiv' }}
|
||||
</flux:button>
|
||||
<flux:button size="sm" variant="danger" icon="trash"
|
||||
wire:click="delete({{ $editingId }})"
|
||||
wire:confirm="Suchindex-Eintrag wirklich loeschen?" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<flux:input wire:model="itemId" label="Item-ID" placeholder="z.B. home, leistungen" />
|
||||
<flux:input wire:model="route" label="Route (Named)" placeholder="z.B. home, leistungen" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<flux:input wire:model="routeParams" label="Route-Parameter (kommagetrennt)"
|
||||
placeholder="z.B. strategische-fmcg-projektrealisierung" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||
<flux:input wire:model="category"
|
||||
label="Kategorie ({{ strtoupper($editLocale) }})"
|
||||
placeholder="z.B. Startseite, Leistungen" />
|
||||
<flux:input wire:model="titleKey" label="Title-Key (CMS)"
|
||||
placeholder="z.B. welcome.title" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||
<flux:input wire:model="titleFallback"
|
||||
label="Title-Fallback ({{ strtoupper($editLocale) }})"
|
||||
placeholder="Fallback wenn kein Key" />
|
||||
<flux:input wire:model="descriptionKey" label="Description-Key (CMS)"
|
||||
placeholder="z.B. welcome.hero.description" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-4">
|
||||
<flux:input wire:model="descriptionFallbackKey" label="Description-Fallback-Key"
|
||||
placeholder="Optionaler Fallback-Key" />
|
||||
<flux:input wire:model="descriptionFallbackText"
|
||||
label="Description-Fallback ({{ strtoupper($editLocale) }})"
|
||||
placeholder="Statischer Fallback-Text" />
|
||||
</div>
|
||||
|
||||
{{-- Keywords --}}
|
||||
<div class="mt-6">
|
||||
<label class="mb-2 block text-sm font-medium">
|
||||
Keywords ({{ strtoupper($editLocale) }})
|
||||
<span class="text-zinc-400">- {{ count($keywords) }} Eintraege</span>
|
||||
</label>
|
||||
|
||||
<div class="mb-3 flex flex-wrap gap-1.5">
|
||||
@foreach ($keywords as $kIdx => $keyword)
|
||||
<span wire:key="kw-{{ $kIdx }}-{{ md5($keyword) }}"
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium
|
||||
{{ str_contains($keyword, '.') ? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300' : 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300' }}">
|
||||
@if (str_contains($keyword, '.'))
|
||||
<x-heroicon-s-key class="h-3 w-3 opacity-50" />
|
||||
@endif
|
||||
{{ $keyword }}
|
||||
<button wire:click="removeKeyword({{ $kIdx }})"
|
||||
class="ml-0.5 text-zinc-400 hover:text-red-500">
|
||||
<x-heroicon-s-x-mark class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<flux:input wire:model="newKeyword" placeholder="Neues Keyword oder CMS-Key..."
|
||||
wire:keydown.enter.prevent="addKeyword" class="flex-1!" />
|
||||
<flux:button wire:click="addKeyword" icon="plus" size="sm">Hinzufuegen</flux:button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-zinc-400">
|
||||
<x-heroicon-s-key class="inline h-3 w-3 text-blue-500" /> = CMS-Key (wird aufgeloest),
|
||||
normale Keywords werden direkt verwendet.
|
||||
Enter druecken oder Button klicken zum Hinzufuegen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Vorschau --}}
|
||||
@php
|
||||
$preview = $currentItem->toFrontendArray($editLocale);
|
||||
@endphp
|
||||
<div class="mt-6 rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">Vorschau
|
||||
({{ strtoupper($editLocale) }})</p>
|
||||
<p class="text-xs text-zinc-400">{{ $preview['category'] }}</p>
|
||||
<p class="text-sm font-semibold text-zinc-800 dark:text-zinc-200">
|
||||
{{ $preview['title'] ?: '(kein Titel)' }}</p>
|
||||
<p class="mt-0.5 line-clamp-2 text-xs text-zinc-500">
|
||||
{{ $preview['description'] ?: '(keine Beschreibung)' }}</p>
|
||||
@if (! empty($preview['url']))
|
||||
<p class="mt-1 truncate text-xs text-blue-500">{{ $preview['url'] }}</p>
|
||||
@endif
|
||||
@if (! empty($preview['keywords']))
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
@foreach (array_slice($preview['keywords'], 0, 10) as $kw)
|
||||
<span
|
||||
class="rounded bg-zinc-200 px-1.5 py-0.5 text-[10px] text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400">{{ $kw }}</span>
|
||||
@endforeach
|
||||
@if (count($preview['keywords']) > 10)
|
||||
<span
|
||||
class="rounded bg-zinc-200 px-1.5 py-0.5 text-[10px] text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400">+{{ count($preview['keywords']) - 10 }}
|
||||
weitere</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<flux:button wire:click="save" variant="primary" icon="check">Speichern</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<div class="flex h-64 items-center justify-center rounded-xl border border-dashed border-zinc-300 dark:border-zinc-700">
|
||||
<div class="text-center">
|
||||
<x-heroicon-o-magnifying-glass class="mx-auto h-10 w-10 text-zinc-300 dark:text-zinc-600" />
|
||||
<p class="mt-2 text-sm text-zinc-400">Eintrag aus der Liste auswaehlen oder neuen erstellen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
<?php
|
||||
|
||||
use Flux\Flux;
|
||||
use FluxCms\Core\Models\CmsContent;
|
||||
use FluxCms\Core\Services\CmsContentService;
|
||||
use function Livewire\Volt\{state, computed, layout, on};
|
||||
|
||||
layout('components.layouts.cms');
|
||||
|
||||
state([
|
||||
'editLocale' => 'de',
|
||||
'showForm' => false,
|
||||
'editingIndex' => null,
|
||||
'name' => '',
|
||||
'role' => '',
|
||||
'image' => '',
|
||||
'imageMediaId' => null,
|
||||
'quote' => '',
|
||||
'preview' => '',
|
||||
'short' => '',
|
||||
'linkedin' => '',
|
||||
]);
|
||||
|
||||
$getProfiles = function (): array {
|
||||
$content = CmsContent::where('group', 'team')->where('key', 'members.profiles')->first();
|
||||
if (!$content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$val = $content->getTranslation('value', $this->editLocale);
|
||||
|
||||
return is_array($val) ? $val : [];
|
||||
};
|
||||
|
||||
$profiles = computed(fn() => $this->getProfiles());
|
||||
|
||||
$create = function () {
|
||||
$this->reset(['editingIndex', 'name', 'role', 'image', 'quote', 'preview', 'short', 'linkedin']);
|
||||
$this->showForm = true;
|
||||
};
|
||||
|
||||
$edit = function (int $index) {
|
||||
$profiles = $this->getProfiles();
|
||||
if (!isset($profiles[$index])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$p = $profiles[$index];
|
||||
$this->editingIndex = $index;
|
||||
$this->showForm = true;
|
||||
$this->name = $p['name'] ?? '';
|
||||
$this->role = $p['role'] ?? '';
|
||||
$this->image = $p['image'] ?? '';
|
||||
$this->quote = $p['quote'] ?? '';
|
||||
$this->preview = $p['preview'] ?? '';
|
||||
$this->short = $p['short'] ?? ($p['kuerzel'] ?? '');
|
||||
$this->linkedin = $p['linkedin'] ?? '';
|
||||
$this->imageMediaId = $this->image ? \FluxCms\Core\Models\CmsMedia::where('filename', $this->image)->first()?->id : null;
|
||||
};
|
||||
|
||||
$save = function () {
|
||||
$content = CmsContent::where('group', 'team')->where('key', 'members.profiles')->first();
|
||||
if (!$content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$profiles = $content->getTranslation('value', $this->editLocale);
|
||||
if (!is_array($profiles)) {
|
||||
$profiles = [];
|
||||
}
|
||||
|
||||
$entry = [
|
||||
'name' => $this->name,
|
||||
'role' => $this->role,
|
||||
'image' => $this->image,
|
||||
'quote' => $this->quote,
|
||||
'preview' => $this->preview,
|
||||
'short' => $this->short,
|
||||
'linkedin' => $this->linkedin,
|
||||
];
|
||||
|
||||
if ($this->editingIndex !== null && isset($profiles[$this->editingIndex])) {
|
||||
$profiles[$this->editingIndex] = $entry;
|
||||
} else {
|
||||
$profiles[] = $entry;
|
||||
}
|
||||
|
||||
$content->setTranslation('value', $this->editLocale, array_values($profiles));
|
||||
$content->save();
|
||||
|
||||
app(CmsContentService::class)->clearCache('team');
|
||||
|
||||
$this->showForm = false;
|
||||
$this->editingIndex = null;
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Teammitglied wurde gespeichert.');
|
||||
};
|
||||
|
||||
$delete = function (int $index) {
|
||||
$content = CmsContent::where('group', 'team')->where('key', 'members.profiles')->first();
|
||||
if (!$content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$profiles = $content->getTranslation('value', $this->editLocale);
|
||||
if (!is_array($profiles) || !isset($profiles[$index])) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($profiles[$index]);
|
||||
|
||||
$content->setTranslation('value', $this->editLocale, array_values($profiles));
|
||||
$content->save();
|
||||
|
||||
app(CmsContentService::class)->clearCache('team');
|
||||
|
||||
Flux::toast(variant: 'success', heading: 'Gelöscht', text: 'Teammitglied wurde entfernt.');
|
||||
};
|
||||
|
||||
$switchLocale = function (string $locale) {
|
||||
$this->editLocale = $locale;
|
||||
if ($this->editingIndex !== null) {
|
||||
$profiles = $this->getProfiles();
|
||||
if (isset($profiles[$this->editingIndex])) {
|
||||
$p = $profiles[$this->editingIndex];
|
||||
$this->name = $p['name'] ?? '';
|
||||
$this->role = $p['role'] ?? '';
|
||||
$this->image = $p['image'] ?? '';
|
||||
$this->quote = $p['quote'] ?? '';
|
||||
$this->preview = $p['preview'] ?? '';
|
||||
$this->short = $p['short'] ?? ($p['kuerzel'] ?? '');
|
||||
$this->linkedin = $p['linkedin'] ?? '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$cancel = function () {
|
||||
$this->showForm = false;
|
||||
$this->editingIndex = null;
|
||||
};
|
||||
|
||||
on([
|
||||
'media-selected' => function (string $field, ?int $mediaId, ?string $url) {
|
||||
if ($field === 'team_image') {
|
||||
$this->imageMediaId = $mediaId;
|
||||
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
|
||||
$this->image = $media ? $media->filename : '';
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<flux:heading size="xl">Team-Verwaltung</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="create">Neu</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($showForm)
|
||||
<flux:card class="mb-6">
|
||||
<flux:heading size="lg" class="mb-4">
|
||||
{{ $editingIndex !== null ? 'Teammitglied bearbeiten' : 'Neues Teammitglied' }}
|
||||
</flux:heading>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<flux:input wire:model="name" label="Name" />
|
||||
<flux:input wire:model="role" label="Position / Rolle" />
|
||||
<flux:input wire:model="short" label="Kürzel" placeholder="z.B. PB" />
|
||||
<flux:input wire:model="linkedin" label="LinkedIn-URL" />
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Profilbild</label>
|
||||
<div class="flex items-start gap-3">
|
||||
@if ($image)
|
||||
<div
|
||||
class="h-16 w-16 shrink-0 overflow-hidden rounded-full 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="$imageMediaId" field="team_image" type="image"
|
||||
profile="avatar" label="Bild wählen" :key="'team-img-' . ($editingIndex ?? 'new')" />
|
||||
<p class="mt-1 text-xs text-zinc-400">{{ $image ?: 'Kein Bild' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<flux:input wire:model="preview" label="Kurzvorstellung (1 Satz)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<flux:editor wire:model="quote" label="Profil-Text (ausführlich)" toolbar="bold italic | link"
|
||||
class="**:data-[slot=content]:min-h-[120px]!" />
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
|
||||
<flux:button variant="ghost" wire:click="cancel">Abbrechen</flux:button>
|
||||
</div>
|
||||
</flux:card>
|
||||
@endif
|
||||
|
||||
<flux:card>
|
||||
<div class="divide-y dark:divide-zinc-700">
|
||||
@forelse ($this->profiles as $index => $profile)
|
||||
<div wire:key="team-{{ $index }}" class="flex items-center justify-between gap-4 py-3">
|
||||
<div class="flex items-center gap-4 min-w-0 flex-1">
|
||||
@if (!empty($profile['image']))
|
||||
<div class="h-12 w-12 shrink-0 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-700">
|
||||
<img src="{{ media_url($profile['image']) }}" alt="{{ $profile['name'] ?? '' }}"
|
||||
class="h-full w-full object-cover" />
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-bold">
|
||||
{{ $profile['short'] ?? mb_substr($profile['name'] ?? '?', 0, 2) }}
|
||||
</div>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium">{{ $profile['name'] ?? '—' }}</div>
|
||||
<p class="truncate text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{{ $profile['role'] ?? '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<flux:button size="sm" variant="ghost" icon="pencil"
|
||||
wire:click="edit({{ $index }})" />
|
||||
<flux:button size="sm" variant="ghost" icon="trash"
|
||||
wire:click="delete({{ $index }})"
|
||||
wire:confirm="'{{ $profile['name'] ?? 'Dieses Mitglied' }}' wirklich löschen?" />
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<flux:text class="py-8 text-center">Keine Teammitglieder vorhanden.</flux:text>
|
||||
@endforelse
|
||||
</div>
|
||||
</flux:card>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-white dark:bg-zinc-800">
|
||||
<flux:sidebar sticky stashable class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:sidebar.toggle class="lg:hidden" icon="x-mark" />
|
||||
|
||||
<a href="{{ route('cms.dashboard') }}" class="me-5 flex items-center space-x-2 rtl:space-x-reverse" wire:navigate>
|
||||
<x-app-logo />
|
||||
</a>
|
||||
|
||||
<flux:navlist variant="outline">
|
||||
<flux:navlist.group heading="CMS" class="grid">
|
||||
<flux:navlist.item icon="home" :href="route('cms.dashboard')"
|
||||
:current="request()->routeIs('cms.dashboard')" wire:navigate>
|
||||
Dashboard
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="document-text" :href="route('cms.content.index')"
|
||||
:current="request()->routeIs('cms.content.*')" wire:navigate>
|
||||
Inhalte
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="newspaper" :href="route('cms.news.index')"
|
||||
:current="request()->routeIs('cms.news.*')" wire:navigate>
|
||||
News Band
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="building-office" :href="route('cms.industries.index')"
|
||||
:current="request()->routeIs('cms.industries.*')" wire:navigate>
|
||||
Industries
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="question-mark-circle" :href="route('cms.faqs.index')"
|
||||
:current="request()->routeIs('cms.faqs.*')" wire:navigate>
|
||||
FAQs
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="chat-bubble-left-right" :href="route('cms.linkedin.index')"
|
||||
:current="request()->routeIs('cms.linkedin.*')" wire:navigate>
|
||||
LinkedIn
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="arrow-down-tray" :href="route('cms.downloads.index')"
|
||||
:current="request()->routeIs('cms.downloads.*')" wire:navigate>
|
||||
Downloads
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="user-group" :href="route('cms.team.index')"
|
||||
:current="request()->routeIs('cms.team.*')" wire:navigate>
|
||||
Team
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="photo" :href="route('cms.media.index')"
|
||||
:current="request()->routeIs('cms.media.*')" wire:navigate>
|
||||
Medienbibliothek
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="magnifying-glass" :href="route('cms.search-index')"
|
||||
:current="request()->routeIs('cms.search-index')" wire:navigate>
|
||||
Suchindex
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
</flux:navlist>
|
||||
|
||||
<flux:spacer />
|
||||
|
||||
<flux:navlist variant="outline">
|
||||
<flux:navlist.item icon="arrow-left" :href="route('dashboard')" wire:navigate>
|
||||
Zurück zum Dashboard
|
||||
</flux:navlist.item>
|
||||
</flux:navlist>
|
||||
|
||||
@auth
|
||||
<flux:dropdown class="hidden lg:block" position="bottom" align="start">
|
||||
<flux:profile :name="auth()->user()->name" :initials="auth()->user()->initials()" />
|
||||
|
||||
<flux:menu class="w-[220px]">
|
||||
<flux:menu.radio.group>
|
||||
<div class="p-0 text-sm font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
|
||||
<span
|
||||
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white">
|
||||
{{ auth()->user()->initials() }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="grid flex-1 text-start text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
|
||||
<span class="truncate text-xs">{{ auth()->user()->email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
||||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
|
||||
{{ __('Settings') }}
|
||||
</flux:menu.item>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
|
||||
{{ __('Log Out') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
@endauth
|
||||
</flux:sidebar>
|
||||
|
||||
@auth
|
||||
<flux:header class="lg:hidden">
|
||||
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
|
||||
<flux:spacer />
|
||||
<flux:dropdown position="top" align="end">
|
||||
<flux:profile :initials="auth()->user()->initials()" icon-trailing="chevron-down" />
|
||||
<flux:menu>
|
||||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
|
||||
{{ __('Settings') }}
|
||||
</flux:menu.item>
|
||||
</flux:menu.radio.group>
|
||||
|
||||
<flux:menu.separator />
|
||||
|
||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item as="button" type="submit" icon="arrow-right-start-on-rectangle" class="w-full">
|
||||
{{ __('Log Out') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</flux:header>
|
||||
@endauth
|
||||
|
||||
<flux:main>
|
||||
{{ $slot }}
|
||||
</flux:main>
|
||||
|
||||
@persist('toast')
|
||||
<flux:toast />
|
||||
@endpersist
|
||||
|
||||
@fluxScripts
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<div class="inline-flex items-center gap-2">
|
||||
<input type="file" wire:model="file" accept="{{ $accept }}"
|
||||
class="text-sm file:mr-2 file:rounded-md file:border-0 file:bg-zinc-100 file:px-3 file:py-1.5 file:text-sm file:font-medium hover:file:bg-zinc-200 dark:file:bg-zinc-700 dark:hover:file:bg-zinc-600" />
|
||||
|
||||
<div wire:loading wire:target="file">
|
||||
<flux:icon name="arrow-path" class="h-4 w-4 animate-spin text-zinc-500" />
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue