10-04-2026

This commit is contained in:
Kevin Adametz 2026-04-10 17:18:17 +02:00
parent 4d6b4930b2
commit 4bb89aad8c
836 changed files with 52961 additions and 5950 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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) }}
&middot; {{ $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>

View file

@ -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>

View file

@ -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>

View file

@ -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>