b2in/resources/views/livewire/admin/cms/content-index.blade.php
Kevin Adametz 451e738c8b CMS: Absatz-Tags aus dem Rich-Text-Editor erscheinen nicht mehr als Text
Der Editor packt eingefügten Text in <p>; Felder wie die FAQ-Antwort gibt die Website als Text
aus, die Tags waren sichtbar. Beim Speichern entfernt unwrapParagraphs() die Absätze (ein
Absatz ohne <p>, mehrere mit <br><br>, Blockinhalte unverändert). cms_inline() gibt die
Felder als bereinigtes Inline-HTML aus – auch schon gespeicherte Texte mit <p> erscheinen
sauber, Fett und Hervorhebung werden dargestellt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:58:48 +00:00

924 lines
47 KiB
PHP

<?php
use App\Services\CmsFluxEditorHtmlTransformer;
use App\Services\CmsPageTree;
use Database\Seeders\CmsContentSeeder;
use Flux\Flux;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\CmsContentService;
use FluxCms\Core\Services\HeroiconOutlineList;
use function Livewire\Volt\{layout, title, state, computed, on};
if (! function_exists('_cmsParseJsonItems')) {
function _cmsParseJsonItems(array $value): array
{
if (empty($value)) {
return [false, []];
}
$isList = array_is_list($value);
$stringify = fn ($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v;
if ($isList && ! is_array($value[0])) {
return [true, array_map(fn ($v) => ['_value' => $stringify($v)], $value)];
}
if ($isList) {
return [false, array_map(fn ($item) => is_array($item)
? array_map($stringify, $item)
: ['_value' => (string) $item], $value)];
}
return [false, [array_map($stringify, $value)]];
}
}
if (! function_exists('_cmsFieldLooksLikeImage')) {
function _cmsFieldLooksLikeImage(string $fieldKey, mixed $fieldValue): bool
{
if (! is_string($fieldValue)) {
return false;
}
$key = strtolower($fieldKey);
if (in_array($key, ['image', 'photo', 'avatar', 'picture', 'thumbnail', 'img', 'logo', 'hero_image', 'background_image', 'cover_image'], true)) {
return true;
}
if (preg_match('/_(image|photo|avatar|picture|thumb|logo)$/i', $fieldKey)) {
return true;
}
if (trim($fieldValue) === '') {
return false;
}
return (bool) preg_match('/\.(jpe?g|png|gif|webp|svg)$/i', $fieldValue);
}
}
if (! function_exists('_cmsFieldLabel')) {
function _cmsFieldLabel(string $field, ?string $group = null, ?string $key = null): string
{
return app(CmsPageTree::class)->fieldLabel($field, $group, $key);
}
}
layout('components.layouts.app');
title('CMS Inhalte');
state([
'selectedGroup' => null,
'selectedKey' => null,
'showPreview' => true,
'previewVersion' => 0,
'search' => '',
'editingId' => null,
'editingField' => null,
'editLocale' => 'de',
'editValue' => '',
'editMediaId' => null,
'showJsonModal' => false,
'jsonItems' => [],
'jsonIsStringArray' => false,
'jsonEditingKey' => '',
'editingFieldType' => 'text',
]);
on(['media-selected' => function (string $field, ?int $mediaId, ?string $url) {
if ($field === 'content_image') {
$media = $mediaId ? CmsMedia::find($mediaId) : null;
if ($media) {
$this->editValue = $media->filename;
$this->editMediaId = $mediaId;
} else {
$this->editValue = '';
$this->editMediaId = null;
}
return;
}
if (str_starts_with($field, 'jsonimg:')) {
$parts = explode(':', $field, 3);
if (count($parts) === 3 && $mediaId) {
$media = CmsMedia::find($mediaId);
if ($media) {
$idx = (int) $parts[1];
$fname = $parts[2];
if (isset($this->jsonItems[$idx]) && is_array($this->jsonItems[$idx])) {
$this->jsonItems[$idx][$fname] = $media->filename;
}
}
}
return;
}
}]);
$groups = computed(fn () => CmsContent::query()
->selectRaw('`group`, count(*) as count')
->groupBy('group')
->orderBy('group')
->pluck('count', 'group')
->toArray());
$tree = computed(fn () => app(CmsPageTree::class)->pages());
$currentSection = computed(fn () => $this->selectedGroup && $this->selectedKey
? app(CmsPageTree::class)->section($this->selectedGroup, $this->selectedKey)
: null);
$previewUrl = computed(fn () => app(CmsPageTree::class)->previewUrl($this->currentSection, $this->previewVersion));
$flatContents = computed(function () {
if (! $this->selectedGroup) {
return collect();
}
$contents = CmsContent::forGroup($this->selectedGroup)
->when($this->selectedKey, fn ($q, $key) => $q->where('key', $key))
->orderBy('order')
->get();
$rows = [];
foreach ($contents as $content) {
$value = $content->getTranslation('value', $this->editLocale);
if ($content->type === 'json' && is_array($value) && ! array_is_list($value)) {
foreach ($value as $fieldKey => $fieldValue) {
if ($this->search && ! str_contains(strtolower($content->key . '.' . $fieldKey), strtolower($this->search))) {
continue;
}
$fieldType = 'text';
if ($this->selectedGroup === 'legal' && $fieldKey === 'content') {
$fieldType = 'legal_html';
} elseif (is_array($fieldValue)) {
$fieldType = 'json';
} elseif (is_string($fieldValue) && preg_match('/<[^>]+>/', $fieldValue)) {
$fieldType = 'html';
} elseif (_cmsFieldLooksLikeImage($fieldKey, $fieldValue)) {
$fieldType = 'image';
}
$rows[] = (object) [
'content_id' => $content->id,
'section_key' => $content->key,
'field_key' => $fieldKey,
'display_key' => $content->key . '.' . $fieldKey,
'type' => $fieldType,
'value' => $fieldValue,
'is_subfield' => true,
];
}
} else {
if ($this->search && ! str_contains(strtolower($content->key), strtolower($this->search))) {
continue;
}
$rows[] = (object) [
'content_id' => $content->id,
'section_key' => $content->key,
'field_key' => null,
'display_key' => $content->key,
'type' => $content->type ?? 'text',
'value' => $value,
'is_subfield' => false,
];
}
}
return collect($rows);
});
$availableIcons = computed(fn () => HeroiconOutlineList::names());
$selectGroup = function (string $group) {
$this->showJsonModal = false;
$this->jsonItems = [];
$this->jsonIsStringArray = false;
$this->jsonEditingKey = '';
$this->editingId = null;
$this->editingField = null;
$this->editValue = '';
$this->editMediaId = null;
$this->editingFieldType = 'text';
$this->selectedGroup = $group;
$this->selectedKey = null;
};
$selectSection = function (string $group, string $key) {
$this->selectGroup($group);
$this->selectedKey = $key;
};
// Sektion aus der Registry, die noch nicht in der Datenbank liegt: aus der Lang-Datei anlegen.
$createSection = function (string $group, string $key) {
$themeKey = app(CmsPageTree::class)->themeKey($group, $key);
if ($themeKey === null) {
Flux::toast(variant: 'danger', heading: 'Keine Vorlage', text: 'Für diese Sektion gibt es keine Vorlage in der Sprachdatei.');
return;
}
$seeder = app(CmsContentSeeder::class);
$seeder->onlySections = [$themeKey];
$seeder->run();
app(CmsContentService::class)->clearCache($group);
$this->selectSection($group, $key);
$this->previewVersion++;
Flux::toast(variant: 'success', heading: 'Angelegt', text: 'Die Sektion wurde mit den Texten der Website angelegt.');
};
$togglePreview = fn () => $this->showPreview = ! $this->showPreview;
$reloadPreview = fn () => $this->previewVersion++;
$startFieldEdit = function (int $contentId, ?string $fieldKey = null) {
$content = CmsContent::find($contentId);
if (! $content) {
return;
}
$fullValue = $content->getTranslation('value', $this->editLocale);
if ($fieldKey !== null && is_array($fullValue)) {
$fieldValue = $fullValue[$fieldKey] ?? '';
if (is_array($fieldValue)) {
$this->editingId = $contentId;
$this->editingField = $fieldKey;
$this->editingFieldType = 'text';
$this->jsonEditingKey = $content->key . '.' . $fieldKey;
$isList = array_is_list($fieldValue);
if ($isList) {
[$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fieldValue);
} else {
$this->jsonIsStringArray = false;
$this->jsonItems = [array_map(
fn ($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v,
$fieldValue,
)];
}
$this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
$this->showJsonModal = true;
return;
}
$this->editingId = $contentId;
$this->editingField = $fieldKey;
$this->editValue = (string) $fieldValue;
if (_cmsFieldLooksLikeImage($fieldKey, $fieldValue)) {
$this->editingFieldType = 'image';
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
} elseif ($content->group === 'legal' && $fieldKey === 'content') {
$this->editingFieldType = 'legal_html';
$this->editMediaId = null;
} else {
$this->editingFieldType = is_string($fieldValue) && preg_match('/<[^>]+>/', $fieldValue) ? 'html' : 'text';
$this->editMediaId = null;
}
if ($this->editingFieldType === 'html') {
$this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue);
}
} else {
$this->editingId = $contentId;
$this->editingField = null;
if ($content->type === 'json') {
$this->jsonEditingKey = $content->key;
if (is_array($fullValue)) {
[$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fullValue);
$this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
} else {
$this->jsonIsStringArray = false;
$this->jsonItems = [];
}
$this->showJsonModal = true;
return;
}
$this->editValue = is_array($fullValue)
? json_encode($fullValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
: ((string) ($fullValue ?? ''));
if ($content->type === 'image') {
$this->editingFieldType = 'image';
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
} elseif ($content->type === 'html') {
$this->editingFieldType = 'html';
$this->editMediaId = null;
$this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue);
} else {
$this->editingFieldType = 'text';
$this->editMediaId = null;
}
}
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId && $this->showJsonModal) {
$content = CmsContent::find($this->editingId);
if (! $content) {
return;
}
$fullValue = $content->getTranslation('value', $locale);
if ($this->editingField !== null && is_array($fullValue)) {
$fieldValue = $fullValue[$this->editingField] ?? [];
if (is_array($fieldValue)) {
[$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fieldValue);
$this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
}
} elseif (is_array($fullValue)) {
[$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fullValue);
$this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
}
} elseif ($this->editingId) {
$content = CmsContent::find($this->editingId);
if (! $content) {
return;
}
$fullValue = $content->getTranslation('value', $locale);
if ($this->editingField !== null && is_array($fullValue)) {
$fieldValue = $fullValue[$this->editingField] ?? '';
$this->editValue = is_array($fieldValue)
? json_encode($fieldValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
: (string) $fieldValue;
if (! is_array($fieldValue)) {
if (_cmsFieldLooksLikeImage((string) $this->editingField, $fieldValue)) {
$this->editingFieldType = 'image';
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
} elseif ($content->group === 'legal' && $this->editingField === 'content') {
$this->editingFieldType = 'legal_html';
$this->editMediaId = null;
} else {
$this->editingFieldType = is_string($fieldValue) && preg_match('/<[^>]+>/', $fieldValue) ? 'html' : 'text';
$this->editMediaId = null;
}
if ($this->editingFieldType === 'html') {
$this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue);
}
}
} else {
$this->editValue = is_array($fullValue)
? json_encode($fullValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
: (string) ($fullValue ?? '');
if ($content->type === 'image') {
$this->editingFieldType = 'image';
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
} elseif ($content->type === 'html') {
$this->editingFieldType = 'html';
$this->editMediaId = null;
$this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue);
} else {
$this->editingFieldType = 'text';
$this->editMediaId = null;
}
}
}
};
$saveEdit = function () {
$content = CmsContent::find($this->editingId);
if (! $content) {
return;
}
$valueToSave = $this->editValue;
if ($this->editingFieldType === 'html') {
$valueToSave = CmsFluxEditorHtmlTransformer::unwrapParagraphs(CmsFluxEditorHtmlTransformer::fromEditor($valueToSave));
}
if ($this->editingField !== null) {
$fullValue = $content->getTranslation('value', $this->editLocale);
if (! is_array($fullValue)) {
$fullValue = [];
}
$fullValue[$this->editingField] = $valueToSave;
$content->setTranslation('value', $this->editLocale, $fullValue);
} else {
$content->setTranslation('value', $this->editLocale, $valueToSave);
}
$content->save();
app(CmsContentService::class)->clearCache($this->selectedGroup);
$this->editingId = null;
$this->editingField = null;
$this->editValue = '';
$this->editingFieldType = 'text';
$this->editMediaId = null;
$this->previewVersion++;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
};
$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;
}
$itemsForSave = CmsFluxEditorHtmlTransformer::fromEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
if ($this->jsonIsStringArray) {
$newValue = array_values(array_map(fn ($item) => $item['_value'] ?? '', $itemsForSave));
} else {
$newValue = array_values(
array_map(function ($item) {
$cleaned = [];
foreach ($item as $k => $v) {
if (is_string($v) && (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;
}, $itemsForSave),
);
}
if ($this->editingField !== null) {
$fullValue = $content->getTranslation('value', $this->editLocale);
if (! is_array($fullValue)) {
$fullValue = [];
}
$fullValue[$this->editingField] = $newValue;
$content->setTranslation('value', $this->editLocale, $fullValue);
} else {
$content->setTranslation('value', $this->editLocale, $newValue);
}
$content->save();
app(CmsContentService::class)->clearCache($this->selectedGroup);
$this->showJsonModal = false;
$this->editingId = null;
$this->editingField = null;
$this->jsonItems = [];
$this->previewVersion++;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
};
$cancelEdit = function () {
$this->editingId = null;
$this->editingField = null;
$this->editValue = '';
$this->editingFieldType = 'text';
$this->editMediaId = null;
};
$cancelJsonModal = function () {
$this->showJsonModal = false;
$this->editingId = null;
$this->editingField = null;
$this->jsonItems = [];
};
?>
@php
$section = $this->currentSection;
$pageTitle = $selectedGroup ? ($this->tree[$section['page'] ?? '']['title'] ?? $selectedGroup) : null;
@endphp
<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 xl:grid-cols-12">
{{-- Seitenbaum: Seiten in Menü-Reihenfolge, Sektionen von oben nach unten (config/cms_pages.php) --}}
<div class="xl:col-span-3">
<flux:card class="space-y-5">
@foreach ($this->tree as $pageKey => $page)
<div wire:key="cms-page-{{ $pageKey }}">
<div class="mb-1 flex items-center justify-between px-3">
<span class="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">{{ $page['title'] }}</span>
@if ($page['path'])
<span class="text-xs text-zinc-400">{{ $page['path'] }}</span>
@endif
</div>
@if ($pageKey === \App\Services\CmsPageTree::UNLISTED)
<details class="px-3">
<summary class="cursor-pointer py-1 text-sm text-zinc-500">{{ count($page['sections']) }} Sektionen stillgelegter Seiten</summary>
<div class="mt-1 flex flex-col gap-0.5">
@foreach ($page['sections'] as $item)
<button wire:key="cms-nav-{{ $item['group'] }}-{{ $item['key'] }}"
wire:click="selectSection('{{ $item['group'] }}', '{{ $item['key'] }}')"
class="rounded-lg px-2 py-1 text-left text-xs transition-colors {{ $selectedGroup === $item['group'] && $selectedKey === $item['key'] ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
{{ $item['title'] }}
</button>
@endforeach
</div>
</details>
@else
<div class="flex flex-col gap-0.5">
@foreach ($page['sections'] as $index => $item)
@php $isActive = $selectedGroup === $item['group'] && $selectedKey === $item['key']; @endphp
@if ($item['content_id'])
<button wire:key="cms-nav-{{ $item['group'] }}-{{ $item['key'] }}"
wire:click="selectSection('{{ $item['group'] }}', '{{ $item['key'] }}')"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-sm transition-colors {{ $isActive ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'hover:bg-zinc-100 dark:hover:bg-zinc-700' }}">
<span class="w-5 shrink-0 text-xs text-zinc-400">{{ $index + 1 }}</span>
<span class="truncate">{{ $item['title'] }}</span>
</button>
@else
<div wire:key="cms-nav-{{ $item['group'] }}-{{ $item['key'] }}"
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-zinc-400">
<span class="w-5 shrink-0 text-xs">{{ $index + 1 }}</span>
<span class="truncate">{{ $item['title'] }}</span>
<flux:button size="xs" variant="ghost" icon="plus" class="ml-auto"
wire:click="createSection('{{ $item['group'] }}', '{{ $item['key'] }}')"
title="Aus den Texten der Website anlegen" />
</div>
@endif
@endforeach
</div>
@endif
</div>
@endforeach
</flux:card>
</div>
{{-- Editor --}}
<div class="{{ $showPreview && $this->previewUrl ? 'xl:col-span-4' : 'xl:col-span-9' }}"
wire:key="cms-content-panel-{{ $selectedGroup ?? 'empty' }}-{{ $selectedKey ?? 'all' }}">
@if ($selectedGroup)
<flux:card>
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<flux:heading size="lg">{{ $section['title'] ?? ($selectedKey ?? $selectedGroup) }}</flux:heading>
@if (! empty($section['description']))
<flux:text class="mt-1 text-sm">{{ $section['description'] }}</flux:text>
@endif
<div class="mt-1 flex items-center gap-2 text-xs text-zinc-400">
@if ($pageTitle && $pageTitle !== ($section['title'] ?? null))
<span>{{ $pageTitle }}</span>
<span aria-hidden="true">·</span>
@endif
<code>{{ $selectedGroup }}{{ $selectedKey ? '/' . $selectedKey : '' }}</code>
</div>
</div>
<div class="flex items-center gap-3">
@if (! $selectedKey)
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen..." size="sm"
icon="magnifying-glass" class="w-48" />
@endif
<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>
@if ($this->previewUrl)
<flux:button size="xs" variant="ghost" :icon="$showPreview ? 'eye-slash' : 'eye'"
wire:click="togglePreview">{{ $showPreview ? 'Vorschau aus' : 'Vorschau' }}</flux:button>
@endif
</div>
</div>
@php $lastSection = null; @endphp
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->flatContents as $row)
@if (! $selectedKey && $row->section_key !== $lastSection)
@php $lastSection = $row->section_key; @endphp
<div class="bg-zinc-50 dark:bg-zinc-800/50 px-3 py-2">
<span class="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">{{ $row->section_key }}</span>
</div>
@endif
@php
$isEditing = $editingId === $row->content_id
&& $editingField === $row->field_key
&& ! $showJsonModal;
$editKey = $row->field_key !== null
? $row->content_id . ",'" . $row->field_key . "'"
: $row->content_id . ',null';
$fieldLabel = $row->field_key !== null
? _cmsFieldLabel($row->field_key, $row->section_key ? $selectedGroup : null, $row->section_key)
: ($section['title'] ?? $row->section_key);
@endphp
<div wire:key="row-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}" class="py-3 px-1">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="mb-1 flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-zinc-800 dark:text-zinc-100">{{ $fieldLabel }}</span>
<code class="rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400">{{ $row->field_key ?? $row->section_key }}</code>
@php
$typeColors = ['html' => 'amber', 'legal_html' => 'blue', 'image' => 'green', 'json' => 'violet', 'link' => 'rose'];
$typeLabels = ['html' => 'Formatierter Text', 'legal_html' => 'HTML', 'image' => 'Bild', 'json' => 'Liste', 'text' => 'Text'];
$badgeColor = $typeColors[$row->type] ?? 'zinc';
@endphp
<flux:badge size="sm" :color="$badgeColor">{{ $typeLabels[$row->type] ?? $row->type }}</flux:badge>
</div>
<div
wire:key="field-value-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}-{{ $isEditing ? 'edit' : 'view' }}"
>
@if ($isEditing)
<div class="mt-2">
@if ($editingFieldType === 'image')
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
@if ($editValue)
<div class="h-24 w-36 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) }}" alt="" class="h-full w-full object-cover" />
</div>
@endif
<div class="min-w-0 flex-1 space-y-2">
<livewire:admin.cms.media-picker
:value="$editMediaId"
field="content_image"
type="image"
profile="card"
label="Bild aus Medienbibliothek wählen"
:key="'cms-content-img-' . $selectedGroup . '-' . $editingId . '-' . ($editingField ?? 'root') . '-' . $editLocale"
/>
<flux:input wire:model="editValue" size="sm" label="Dateiname (optional manuell)" placeholder="z. B. b2in/hero.jpg" />
</div>
</div>
@elseif ($editingFieldType === 'html')
<flux:editor wire:model="editValue" toolbar="bold italic highlight"
class="**:data-[slot=content]:min-h-[60px]!" />
@elseif ($editingFieldType === 'legal_html')
{{-- natives textarea, Inhalt nur per Livewire-JS (kein </textarea> im HTML); verhindert DOM-Bruch bei Impressum-HTML --}}
<div class="space-y-2">
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300" for="legal-html-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}">HTML (Rohtext)</label>
<textarea
id="legal-html-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}"
wire:model.defer="editValue"
rows="28"
spellcheck="false"
class="block min-h-[24rem] w-full resize-y rounded-lg border border-zinc-200 bg-white p-3 font-mono text-xs text-zinc-800 shadow-xs dark:border-white/10 dark:bg-white/10 dark:text-zinc-200"
></textarea>
</div>
@else
<flux:textarea wire:model="editValue" rows="8" class="font-mono text-sm" />
@endif
<div class="mt-2 flex gap-2">
<flux:button size="sm" variant="primary" wire:click="saveEdit" wire:loading.attr="disabled" wire:target="saveEdit">
<span wire:loading.remove wire:target="saveEdit">Speichern</span>
<span wire:loading wire:target="saveEdit">Speichern…</span>
</flux:button>
<flux:button size="sm" variant="ghost" wire:click="cancelEdit" wire:loading.attr="disabled" wire:target="saveEdit">Abbrechen</flux:button>
</div>
</div>
@else
@if ($row->type === 'json' && is_array($row->value))
@php
$isList = array_is_list($row->value);
$cnt = count($row->value);
@endphp
<div class="flex items-center gap-2 text-sm text-zinc-500">
<flux:badge size="sm" color="violet">{{ $cnt }} {{ $isList ? 'Einträge' : 'Felder' }}</flux:badge>
@if ($isList && $cnt > 0 && is_array($row->value[0] ?? null))
<span class="text-xs text-zinc-400">{{ implode(', ', array_keys($row->value[0])) }}</span>
@elseif (! $isList)
<span class="text-xs text-zinc-400">{{ implode(', ', array_slice(array_keys($row->value), 0, 5)) }}{{ $cnt > 5 ? ', …' : '' }}</span>
@endif
</div>
@elseif ($row->type === 'image' && is_string($row->value) && $row->value !== '')
<div class="flex items-center gap-3">
<div class="h-14 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($row->value) }}" alt="" class="h-full w-full object-cover" loading="lazy" />
</div>
<span class="truncate text-xs text-zinc-500">{{ $row->value }}</span>
</div>
@elseif ($row->type === 'legal_html')
<p class="line-clamp-3 text-sm text-zinc-600 dark:text-zinc-400">
{{ \Illuminate\Support\Str::limit(strip_tags((string) $row->value), 200) }}
</p>
@elseif ($row->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((string) $row->value, 200) !!}
</div>
@else
<p class="truncate text-sm text-zinc-800 dark:text-zinc-200">
{{ \Illuminate\Support\Str::limit(strip_tags(is_array($row->value) ? json_encode($row->value, JSON_UNESCAPED_UNICODE) : (string) ($row->value ?? '')), 120) }}
</p>
@endif
@endif
</div>
</div>
@if (! $isEditing)
<flux:button size="sm" variant="ghost" icon="pencil"
wire:click="startFieldEdit({{ $editKey }})" />
@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>Sektion auswählen</flux:heading>
<flux:text>Links stehen die Seiten der Website mit ihren Abschnitten von oben nach unten. Wähle einen Abschnitt, um seine Texte und Bilder zu bearbeiten.</flux:text>
</div>
</flux:card>
@endif
</div>
{{-- Vorschau: die echte Website im Rahmen, springt zur gewählten Sektion und lädt nach dem Speichern neu --}}
@if ($selectedGroup && $showPreview && $this->previewUrl)
<div class="xl:col-span-5">
<flux:card class="sticky top-4 overflow-hidden p-0!">
<div class="flex items-center justify-between gap-2 border-b border-zinc-200 px-4 py-2 dark:border-zinc-700">
<div class="min-w-0 truncate text-xs text-zinc-500">
Vorschau · {{ $section['path'] ?? '' }}{{ ! empty($section['anchor']) ? '#' . $section['anchor'] : '' }}
</div>
<div class="flex shrink-0 items-center gap-1">
<flux:button size="xs" variant="ghost" icon="arrow-path" wire:click="reloadPreview" title="Neu laden" />
<flux:button size="xs" variant="ghost" icon="arrow-top-right-on-square" :href="$this->previewUrl" target="_blank" title="In neuem Tab öffnen" />
</div>
</div>
<iframe wire:key="cms-preview-{{ md5($this->previewUrl) }}" src="{{ $this->previewUrl }}"
class="h-[78vh] w-full bg-white" title="Vorschau der Website" loading="lazy"></iframe>
</flux:card>
</div>
@endif
</div>
{{-- JSON-Editor: kein flux:modal — vermeidet ReferenceError fluxModal bei jedem Livewire-Render (z. B. bei legal_html). --}}
@if ($showJsonModal)
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
wire:key="cms-json-editor-overlay-{{ $selectedGroup ?? 'none' }}"
x-data
x-on:keydown.escape.window.prevent="$wire.cancelJsonModal()"
>
<div class="fixed inset-0 bg-zinc-950/50 backdrop-blur-sm" wire:click="cancelJsonModal" aria-hidden="true">
</div>
<div
role="dialog"
aria-modal="true"
class="relative z-10 flex w-full max-w-5xl max-h-[90vh] flex-col overflow-y-auto space-y-6 rounded-xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
wire:click.stop
>
<div>
<flux:heading size="lg">{{ $section['title'] ?? $selectedGroup }}: {{ $editingField !== null ? _cmsFieldLabel($editingField, $selectedGroup, $selectedKey) : ($section['title'] ?? $jsonEditingKey) }}</flux:heading>
<flux:text class="mt-1">
<code class="text-xs">{{ $jsonEditingKey }}</code> ·
{{ $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']);
$isNestedJson = is_string($fieldValue) && (str_starts_with($fieldValue, '[') || str_starts_with($fieldValue, '{'));
$isImageField = _cmsFieldLooksLikeImage($field, $fieldValue);
$jsonImageMediaId = is_string($fieldValue) && $fieldValue !== '' ? \FluxCms\Core\Models\CmsMedia::where('filename', $fieldValue)->first()?->id : null;
@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="{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }}"
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 ($isImageField)
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }}</label>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
@if (is_string($fieldValue) && $fieldValue !== '')
<div class="h-20 w-32 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($fieldValue) }}" alt="" class="h-full w-full object-cover" />
</div>
@endif
<div class="min-w-0 flex-1 space-y-2">
<livewire:admin.cms.media-picker
:value="$jsonImageMediaId"
:field="'jsonimg:' . $idx . ':' . $field"
type="image"
profile="card"
label="Bild wählen"
:key="'json-img-' . ($selectedGroup ?? '') . '-' . $jsonEditingKey . '-' . $idx . '-' . $field . '-' . $editLocale"
/>
<flux:input wire:model="jsonItems.{{ $idx }}.{{ $field }}" size="sm" placeholder="Dateiname (optional manuell)" />
</div>
</div>
</div>
@elseif ($isRichText)
<div class="md:col-span-2">
<flux:editor wire:model="jsonItems.{{ $idx }}.{{ $field }}"
label="{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }}" toolbar="bold italic highlight"
class="**:data-[slot=content]:min-h-[60px]!" />
</div>
@elseif ($isNestedJson)
<div class="md:col-span-2">
<flux:textarea wire:model="jsonItems.{{ $idx }}.{{ $field }}"
label="{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }} (JSON)" rows="3"
class="font-mono text-xs" />
</div>
@else
<flux:input wire:model="jsonItems.{{ $idx }}.{{ $field }}"
label="{{ _cmsFieldLabel($field, $selectedGroup, $selectedKey) }}" />
@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>
</div>
</div>
@endif
</div>