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

View file

@ -0,0 +1,307 @@
<div>
<flux:header class="mb-6">
<flux:heading size="xl">{{ __('Cabinet Display - CMS Verwaltung') }}</flux:heading>
<flux:subheading>{{ __('Verwalten Sie die Inhalte der Display-Seite') }}</flux:subheading>
</flux:header>
{{-- Hilfe-Banner --}}
<flux:card class="mb-6 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
<div class="flex items-start gap-4">
<flux:icon.information-circle class="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div class="flex-1">
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">{{ __('Schnellanleitung') }}</h3>
<div class="text-sm text-blue-800 dark:text-blue-200 space-y-1">
<p> <strong>Videos:</strong> Videos müssen aufgrund der Dateigrößer vorab per SFTP hochgeladen werden. Die Position (0-100%) bestimmt den vertikalen Bildausschnitt.</p>
<p> <strong>Footer-Inhalte:</strong> Werden alle 30 Sekunden gewechselt. URLs werden automatisch als QR-Code angezeigt.</p>
<p> <strong>Footer-Inhalte:</strong> Sind alle Inhalte ausgeblendet, wird der Footer ausgeblendet und das Video auf 100% der Höhe angezeigt.</p>
<p> <strong>Display-URL:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">https://cabinet.b2in.eu</code></p>
<p> <strong>API-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">{{ url('/api/display/config') }}</code></p>
</div>
</div>
</div>
</flux:card>
{{-- Success-Meldungen --}}
@if (session()->has('success'))
<x-success-alert>
{{ session('success') }}
</x-success-alert>
@endif
{{-- Video-Verwaltung --}}
<flux:card class="mb-8">
<div class="flex items-center justify-between mb-6">
<div>
<flux:heading size="lg">{{ __('Video-Playlist') }}</flux:heading>
<flux:subheading>{{ __('Videos werden in der angegebenen Reihenfolge abgespielt') }}</flux:subheading>
</div>
<flux:button wire:click="openVideoModal" icon="plus">
{{ __('Video hinzufügen') }}
</flux:button>
</div>
@if($videos->isEmpty())
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
<flux:icon.film class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>{{ __('Noch keine Videos vorhanden. Fügen Sie Ihr erstes Video hinzu!') }}</p>
</div>
@else
<div class="space-y-3">
@foreach($videos as $index => $video)
<div wire:key="video-{{ $video->id }}"
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600 transition">
<div class="flex flex-col gap-1">
@if($index > 0)
<flux:button wire:click="moveVideo({{ $video->id }}, 'up')"
size="xs"
variant="ghost"
icon="chevron-up"
class="text-zinc-400 hover:text-zinc-600">
</flux:button>
@endif
@if($index < count($videos) - 1)
<flux:button wire:click="moveVideo({{ $video->id }}, 'down')"
size="xs"
variant="ghost"
icon="chevron-down"
class="text-zinc-400 hover:text-zinc-600">
</flux:button>
@endif
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1">
<flux:badge :color="$video->is_active ? 'green' : 'zinc'" size="sm">
{{ $video->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
<span class="font-semibold text-sm">{{ $video->title ?: $video->filename }}</span>
</div>
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4">
<span>📁 {{ $video->filename }}</span>
<span>📍 Position: {{ $video->position }}%</span>
</div>
</div>
<div class="flex items-center gap-2">
<flux:button wire:click="toggleVideoStatus({{ $video->id }})"
size="sm"
variant="ghost"
:icon="$video->is_active ? 'eye-slash' : 'eye'">
</flux:button>
<flux:button wire:click="openVideoModal({{ $video->id }})"
size="sm"
variant="ghost"
icon="pencil">
</flux:button>
<flux:button wire:click="deleteVideo({{ $video->id }})"
wire:confirm="Möchten Sie dieses Video wirklich löschen?"
size="sm"
variant="ghost"
icon="trash"
class="text-red-600 hover:text-red-700">
</flux:button>
</div>
</div>
@endforeach
</div>
@endif
</flux:card>
{{-- Footer-Content-Verwaltung --}}
<flux:card>
<div class="flex items-center justify-between mb-6">
<div>
<flux:heading size="lg">{{ __('Footer-Inhalte') }}</flux:heading>
<flux:subheading>{{ __('Inhalte werden alle 30 Sekunden im Footer gewechselt') }}</flux:subheading>
@if($footerContents->isNotEmpty())
<div class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
📊 Gesamt-Klicks: <strong>{{ $footerContents->sum('clicks') }}</strong>
</div>
@endif
</div>
<flux:button wire:click="openFooterModal" icon="plus">
{{ __('Inhalt hinzufügen') }}
</flux:button>
</div>
@if($footerContents->isEmpty())
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
<flux:icon.document-text class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>{{ __('Noch keine Footer-Inhalte vorhanden. Fügen Sie den ersten Inhalt hinzu!') }}</p>
</div>
@else
<div class="space-y-3">
@foreach($footerContents as $index => $footer)
<div wire:key="footer-{{ $footer->id }}"
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600 transition">
<div class="flex flex-col gap-1">
@if($index > 0)
<flux:button wire:click="moveFooter({{ $footer->id }}, 'up')"
size="xs"
variant="ghost"
icon="chevron-up"
class="text-zinc-400 hover:text-zinc-600">
</flux:button>
@endif
@if($index < count($footerContents) - 1)
<flux:button wire:click="moveFooter({{ $footer->id }}, 'down')"
size="xs"
variant="ghost"
icon="chevron-down"
class="text-zinc-400 hover:text-zinc-600">
</flux:button>
@endif
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1">
<flux:badge :color="$footer->is_active ? 'green' : 'zinc'" size="sm">
{{ $footer->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
<span class="font-semibold text-sm">{{ $footer->headline }}</span>
<flux:badge color="blue" size="sm">
<flux:icon.cursor-arrow-rays class="w-3 h-3" />
{{ $footer->clicks }} {{ __('Klicks') }}
</flux:badge>
</div>
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-y-1">
<div>{{ $footer->subline }}</div>
@if($footer->url)
<div class="flex items-center gap-1">
<flux:icon.link class="w-3 h-3" />
<span class="font-mono bg-zinc-100 dark:bg-zinc-700 px-2 py-0.5 rounded">
{{ $footer->short_code }}
</span>
<button
onclick="navigator.clipboard.writeText('{{ $footer->short_url }}'); alert('Short-Link kopiert!');"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 text-xs"
title="Short-Link kopieren">
📋 Short-Link
</button>
</div>
<div class="flex items-center gap-1 text-xs">
<flux:icon.arrow-top-right-on-square class="w-3 h-3" />
<a href="{{ $footer->url }}" target="_blank" class="hover:underline">
{{ Str::limit($footer->url, 50) }}
</a>
</div>
@else
<div class="flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-500">
<flux:icon.x-circle class="w-3 h-3" />
<span>Kein QR-Code (Keine URL angegeben)</span>
</div>
@endif
</div>
</div>
<div class="flex items-center gap-2">
<flux:button wire:click="toggleFooterStatus({{ $footer->id }})"
size="sm"
variant="ghost"
:icon="$footer->is_active ? 'eye-slash' : 'eye'"
title="{{ $footer->is_active ? 'Deaktivieren' : 'Aktivieren' }}">
</flux:button>
<flux:dropdown>
<flux:button size="sm" variant="ghost" icon="ellipsis-vertical"></flux:button>
<flux:menu>
<flux:menu.item wire:click="openFooterModal({{ $footer->id }})" icon="pencil">
{{ __('Bearbeiten') }}
</flux:menu.item>
<flux:menu.item wire:click="regenerateShortCode({{ $footer->id }})" icon="arrow-path">
{{ __('Short-Code neu generieren') }}
</flux:menu.item>
<flux:menu.item wire:click="resetClicks({{ $footer->id }})"
wire:confirm="Möchten Sie den Klick-Zähler wirklich zurücksetzen?"
icon="arrow-path-rounded-square">
{{ __('Klicks zurücksetzen') }}
</flux:menu.item>
<flux:menu.separator />
<flux:menu.item wire:click="deleteFooter({{ $footer->id }})"
wire:confirm="Möchten Sie diesen Footer-Inhalt wirklich löschen?"
icon="trash"
class="text-red-600">
{{ __('Löschen') }}
</flux:menu.item>
</flux:menu>
</flux:dropdown>
</div>
</div>
@endforeach
</div>
@endif
</flux:card>
{{-- Video Modal --}}
<flux:modal :open="$showVideoModal" wire:model="showVideoModal">
<form wire:submit.prevent="saveVideo">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ $videoId ? __('Video bearbeiten') : __('Video hinzufügen') }}</flux:heading>
</div>
<flux:select wire:model="videoFilename" label="Video-Datei" placeholder="Wählen Sie ein Video...">
@foreach($availableVideos as $videoFile)
<option value="{{ $videoFile }}">{{ $videoFile }}</option>
@endforeach
</flux:select>
@error('videoFilename') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:input wire:model="videoTitle" label="Titel (optional)" placeholder="z.B. Herbst Kollektion 2025" />
<flux:input wire:model="videoPosition" type="number" min="0" max="100" label="Position (%)"
description="Vertikale Position im Video (0 = oben, 100 = unten)" />
@error('videoPosition') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:checkbox wire:model="videoIsActive" label="Video aktiv anzeigen" />
<div class="flex justify-end gap-3 pt-4">
<flux:button type="button" wire:click="closeVideoModal" variant="ghost">
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ $videoId ? __('Aktualisieren') : __('Hinzufügen') }}
</flux:button>
</div>
</div>
</form>
</flux:modal>
{{-- Footer Modal --}}
<flux:modal :open="$showFooterModal" wire:model="showFooterModal">
<form wire:submit.prevent="saveFooter">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ $footerId ? __('Footer-Inhalt bearbeiten') : __('Footer-Inhalt hinzufügen') }}</flux:heading>
</div>
<flux:input wire:model="footerHeadline" label="Überschrift" placeholder="z.B. Beratung & Termin" />
@error('footerHeadline') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:input wire:model="footerSubline" label="Unterzeile" placeholder="z.B. Jetzt Termin vereinbaren." />
@error('footerSubline') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:input wire:model="footerUrl" label="URL (optional)" placeholder="https://www.cabinet.de/bielefeld..."
description="Leer lassen = Kein QR-Code wird angezeigt. Mit URL = QR-Code mit Short-Link wird generiert." />
@error('footerUrl') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:checkbox wire:model="footerIsActive" label="Footer-Inhalt aktiv anzeigen" />
<div class="flex justify-end gap-3 pt-4">
<flux:button type="button" wire:click="closeFooterModal" variant="ghost">
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ $footerId ? __('Aktualisieren') : __('Hinzufügen') }}
</flux:button>
</div>
</div>
</form>
</flux:modal>
</div>

View file

@ -0,0 +1,165 @@
<div>
<flux:header class="mb-6">
<flux:heading size="xl">{{ __('Cabinet Info-Tablet') }}</flux:heading>
<flux:subheading>{{ __('Einstellungen für das Schaufenster-Tablet') }}</flux:subheading>
</flux:header>
{{-- Hilfe-Banner --}}
<flux:card class="mb-6 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
<div class="flex items-start gap-4">
<flux:icon.information-circle class="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div class="flex-1">
<h3 class="font-semibold text-blue-900 dark:text-blue-100 mb-2">{{ __('Info-Tablet Steuerung') }}</h3>
<div class="text-sm text-blue-800 dark:text-blue-200 space-y-1">
<p>Das Info-Tablet im Schaufenster zeigt Store-Status, Öffnungszeiten und den nächsten freien Termin.</p>
<p> <strong>Automatisch:</strong> Offen/Geschlossen wird automatisch aus den hinterlegten Öffnungszeiten berechnet.</p>
<p> <strong>API-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">{{ url('/api/cabinet-tablet/status') }}</code></p>
<p> <strong>Website-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank" href="https://cabinet.b2in.eu/info">https://cabinet.b2in.eu/info</a></code></p>
<p> <strong>Quick-Status-Endpunkt:</strong> <code class="px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded"><a target="_blank" href="https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}">https://portal.b2in.eu/info/status?key={{ config('domains.cabinet_status_key') }}</a></code></p>
<p> <strong>Quick-Status-Hinweis:</strong> Der Quick-Status-Endpunkt ist ein kleines Tool, um den Store-Status schnell zu ändern. Auf dem Mobiltelefon den Link öffen und dem Home-Screen hinzufügen.</p>
</div>
</div>
</div>
</flux:card>
{{-- Success-Meldungen --}}
@if (session()->has('success'))
<x-success-alert>
{{ session('success') }}
</x-success-alert>
@endif
<form wire:submit.prevent="save"
x-data
x-on:submit.prevent="
const hours = {};
document.querySelectorAll('[data-hours-prop]').forEach(el => {
const prop = el.dataset.hoursProp;
const picker = el.querySelector('ui-time-picker');
hours[prop] = picker ? (picker.value ?? '') : '';
});
$wire.save(hours);
">
<div class="space-y-6">
{{-- Store-Status --}}
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Store-Status') }}</flux:heading>
<div class="space-y-4">
<flux:select wire:model.live="storeStatus" label="Modus">
<option value="auto">Automatisch (aus Öffnungszeiten)</option>
<option value="notice">Hinweis (Orange)</option>
<option value="warning">Warnung (Rot)</option>
<option value="closed">Manuell geschlossen (Gelb)</option>
</flux:select>
@if($storeStatus !== 'auto')
@php
$noticeColors = match($storeStatus) {
'notice' => 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800',
'warning' => 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800',
default => 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800',
};
@endphp
<div class="space-y-4 p-4 rounded-lg {{ $noticeColors }}">
<flux:input wire:model="noticeHeadline" label="Headline" placeholder="z.B. Heute erst ab 11:00 Uhr" maxlength="40" />
@error('noticeHeadline') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:input wire:model="noticeSubtext" label="Subtext (optional)" placeholder="z.B. Wegen eines Kundentermins öffnen wir heute später." maxlength="80" />
@error('noticeSubtext') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
@endif
</div>
</flux:card>
{{-- Öffnungszeiten --}}
<flux:card>
<flux:heading size="lg" class="mb-1">{{ __('Öffnungszeiten') }}</flux:heading>
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">Felder leer lassen = Geschlossen. Der Status "Geöffnet/Geschlossen" wird automatisch aus diesen Zeiten berechnet.</p>
<div class="space-y-3">
@foreach([
['label' => 'Montag', 'open' => 'hoursMondayOpen', 'close' => 'hoursMondayClose'],
['label' => 'Dienstag', 'open' => 'hoursTuesdayOpen', 'close' => 'hoursTuesdayClose'],
['label' => 'Mittwoch', 'open' => 'hoursWednesdayOpen', 'close' => 'hoursWednesdayClose'],
['label' => 'Donnerstag', 'open' => 'hoursThursdayOpen', 'close' => 'hoursThursdayClose'],
['label' => 'Freitag', 'open' => 'hoursFridayOpen', 'close' => 'hoursFridayClose'],
['label' => 'Samstag', 'open' => 'hoursSaturdayOpen', 'close' => 'hoursSaturdayClose'],
['label' => 'Sonntag', 'open' => 'hoursSundayOpen', 'close' => 'hoursSundayClose'],
] as $row)
@php
$isClosedDay = empty($this->{$row['open']}) && empty($this->{$row['close']});
@endphp
<div class="grid grid-cols-[120px_1fr_1fr_80px] items-center gap-3 py-2 border-b border-zinc-100 dark:border-zinc-800 last:border-0">
<span class="text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ $row['label'] }}</span>
<div data-hours-prop="{{ $row['open'] }}">
<flux:time-picker wire:model.live="{{ $row['open'] }}" label="Öffnung" time-format="24-hour" interval="15" clearable placeholder="" />
</div>
<div data-hours-prop="{{ $row['close'] }}">
<flux:time-picker wire:model.live="{{ $row['close'] }}" label="Schluss" time-format="24-hour" interval="15" clearable placeholder="" />
</div>
<span class="text-xs text-center {{ $isClosedDay ? 'text-red-500 dark:text-red-400' : 'text-emerald-600 dark:text-emerald-400' }}">
{{ $isClosedDay ? 'Geschlossen' : 'Geöffnet' }}
</span>
</div>
@endforeach
</div>
</flux:card>
{{-- Sonderöffnung heute
<flux:card>
<div class="flex items-center justify-between mb-4">
<flux:heading size="lg">{{ __('Sonderöffnung heute') }}</flux:heading>
@if($overrideOpenToday || $overrideCloseToday)
<flux:button wire:click="clearOverrides" size="sm" variant="ghost" icon="x-mark">
{{ __('Zurücksetzen') }}
</flux:button>
@endif
</div>
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4">Überschreibt die reguläre Öffnungszeit für heute. Wird um Mitternacht automatisch zurückgesetzt.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<flux:time-picker wire:model="overrideOpenToday" label="Öffnung" time-format="24-hour" interval="15" clearable placeholder="" />
@error('overrideOpenToday') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:time-picker wire:model="overrideCloseToday" label="Schluss" time-format="24-hour" interval="15" clearable placeholder="" />
@error('overrideCloseToday') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
</flux:card>--}}
{{-- Nächster Termin
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Nächster freier Termin') }}</flux:heading>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<flux:input wire:model="nextAppointmentDate" type="date" label="Datum" />
@error('nextAppointmentDate') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:time-picker wire:model="nextAppointmentTime" label="Uhrzeit" time-format="24-hour" interval="15" clearable placeholder="" />
@error('nextAppointmentTime') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
</flux:card>--}}
{{-- Kontakt --}}
<flux:card>
<flux:heading size="lg" class="mb-4">{{ __('Kontakt') }}</flux:heading>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<flux:input wire:model="contactPhone" label="Telefon" placeholder="z.B. 0521 98620100" />
@error('contactPhone') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:input wire:model="contactEmail" label="E-Mail" type="email" placeholder="z.B. info@cabinet-bielefeld.de" />
@error('contactEmail') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
</flux:card>
{{-- Speichern --}}
<div class="flex justify-end">
<flux:button type="submit" variant="primary" icon="check">
{{ __('Einstellungen speichern') }}
</flux:button>
</div>
</div>
</form>
</div>

View file

@ -0,0 +1,782 @@
<?php
use App\Services\CmsFluxEditorHtmlTransformer;
use Flux\Flux;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\CmsContentService;
use FluxCms\Core\Services\HeroiconOutlineList;
use function Livewire\Volt\{layout, title, state, computed, on};
if (! function_exists('_cmsParseJsonItems')) {
function _cmsParseJsonItems(array $value): array
{
if (empty($value)) {
return [false, []];
}
$isList = array_is_list($value);
$stringify = fn ($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v;
if ($isList && ! is_array($value[0])) {
return [true, array_map(fn ($v) => ['_value' => $stringify($v)], $value)];
}
if ($isList) {
return [false, array_map(fn ($item) => is_array($item)
? array_map($stringify, $item)
: ['_value' => (string) $item], $value)];
}
return [false, [array_map($stringify, $value)]];
}
}
if (! function_exists('_cmsFieldLooksLikeImage')) {
function _cmsFieldLooksLikeImage(string $fieldKey, mixed $fieldValue): bool
{
if (! is_string($fieldValue) || trim($fieldValue) === '') {
return false;
}
$key = strtolower($fieldKey);
if (in_array($key, ['image', 'photo', 'avatar', 'picture', 'thumbnail', 'img', 'hero_image', 'background_image', 'cover_image'], true)) {
return true;
}
if (preg_match('/_(image|photo|avatar|picture|thumb)$/i', $fieldKey)) {
return true;
}
return (bool) preg_match('/\.(jpe?g|png|gif|webp|svg)$/i', $fieldValue);
}
}
layout('components.layouts.app');
title('CMS Inhalte');
state([
'selectedGroup' => null,
'search' => '',
'editingId' => null,
'editingField' => null,
'editLocale' => 'de',
'editValue' => '',
'editMediaId' => null,
'showJsonModal' => false,
'jsonItems' => [],
'jsonIsStringArray' => false,
'jsonEditingKey' => '',
'editingFieldType' => 'text',
]);
on(['media-selected' => function (string $field, ?int $mediaId, ?string $url) {
if ($field === 'content_image') {
$media = $mediaId ? CmsMedia::find($mediaId) : null;
if ($media) {
$this->editValue = $media->filename;
$this->editMediaId = $mediaId;
} else {
$this->editValue = '';
$this->editMediaId = null;
}
return;
}
if (str_starts_with($field, 'jsonimg:')) {
$parts = explode(':', $field, 3);
if (count($parts) === 3 && $mediaId) {
$media = CmsMedia::find($mediaId);
if ($media) {
$idx = (int) $parts[1];
$fname = $parts[2];
if (isset($this->jsonItems[$idx]) && is_array($this->jsonItems[$idx])) {
$this->jsonItems[$idx][$fname] = $media->filename;
}
}
}
return;
}
}]);
$groups = computed(fn () => CmsContent::query()
->selectRaw('`group`, count(*) as count')
->groupBy('group')
->orderBy('group')
->pluck('count', 'group')
->toArray());
$flatContents = computed(function () {
if (! $this->selectedGroup) {
return collect();
}
$contents = CmsContent::forGroup($this->selectedGroup)
->orderBy('order')
->get();
$rows = [];
foreach ($contents as $content) {
$value = $content->getTranslation('value', $this->editLocale);
if ($content->type === 'json' && is_array($value) && ! array_is_list($value)) {
foreach ($value as $fieldKey => $fieldValue) {
if ($this->search && ! str_contains(strtolower($content->key . '.' . $fieldKey), strtolower($this->search))) {
continue;
}
$fieldType = 'text';
if ($this->selectedGroup === 'legal' && $fieldKey === 'content') {
$fieldType = 'legal_html';
} elseif (is_array($fieldValue)) {
$fieldType = 'json';
} elseif (is_string($fieldValue) && preg_match('/<[^>]+>/', $fieldValue)) {
$fieldType = 'html';
} elseif (_cmsFieldLooksLikeImage($fieldKey, $fieldValue)) {
$fieldType = 'image';
}
$rows[] = (object) [
'content_id' => $content->id,
'section_key' => $content->key,
'field_key' => $fieldKey,
'display_key' => $content->key . '.' . $fieldKey,
'type' => $fieldType,
'value' => $fieldValue,
'is_subfield' => true,
];
}
} else {
if ($this->search && ! str_contains(strtolower($content->key), strtolower($this->search))) {
continue;
}
$rows[] = (object) [
'content_id' => $content->id,
'section_key' => $content->key,
'field_key' => null,
'display_key' => $content->key,
'type' => $content->type ?? 'text',
'value' => $value,
'is_subfield' => false,
];
}
}
return collect($rows);
});
$availableIcons = computed(fn () => HeroiconOutlineList::names());
$selectGroup = function (string $group) {
$this->showJsonModal = false;
$this->jsonItems = [];
$this->jsonIsStringArray = false;
$this->jsonEditingKey = '';
$this->editingId = null;
$this->editingField = null;
$this->editValue = '';
$this->editMediaId = null;
$this->editingFieldType = 'text';
$this->selectedGroup = $group;
};
$startFieldEdit = function (int $contentId, ?string $fieldKey = null) {
$content = CmsContent::find($contentId);
if (! $content) {
return;
}
$fullValue = $content->getTranslation('value', $this->editLocale);
if ($fieldKey !== null && is_array($fullValue)) {
$fieldValue = $fullValue[$fieldKey] ?? '';
if (is_array($fieldValue)) {
$this->editingId = $contentId;
$this->editingField = $fieldKey;
$this->editingFieldType = 'text';
$this->jsonEditingKey = $content->key . '.' . $fieldKey;
$isList = array_is_list($fieldValue);
if ($isList) {
[$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fieldValue);
} else {
$this->jsonIsStringArray = false;
$this->jsonItems = [array_map(
fn ($v) => is_array($v) ? json_encode($v, JSON_UNESCAPED_UNICODE) : (string) $v,
$fieldValue,
)];
}
$this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
$this->showJsonModal = true;
return;
}
$this->editingId = $contentId;
$this->editingField = $fieldKey;
$this->editValue = (string) $fieldValue;
if (_cmsFieldLooksLikeImage($fieldKey, $fieldValue)) {
$this->editingFieldType = 'image';
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
} elseif ($content->group === 'legal' && $fieldKey === 'content') {
$this->editingFieldType = 'legal_html';
$this->editMediaId = null;
} else {
$this->editingFieldType = is_string($fieldValue) && preg_match('/<[^>]+>/', $fieldValue) ? 'html' : 'text';
$this->editMediaId = null;
}
if ($this->editingFieldType === 'html') {
$this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue);
}
} else {
$this->editingId = $contentId;
$this->editingField = null;
if ($content->type === 'json') {
$this->jsonEditingKey = $content->key;
if (is_array($fullValue)) {
[$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fullValue);
$this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
} else {
$this->jsonIsStringArray = false;
$this->jsonItems = [];
}
$this->showJsonModal = true;
return;
}
$this->editValue = is_array($fullValue)
? json_encode($fullValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
: ((string) ($fullValue ?? ''));
if ($content->type === 'image') {
$this->editingFieldType = 'image';
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
} elseif ($content->type === 'html') {
$this->editingFieldType = 'html';
$this->editMediaId = null;
$this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue);
} else {
$this->editingFieldType = 'text';
$this->editMediaId = null;
}
}
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId && $this->showJsonModal) {
$content = CmsContent::find($this->editingId);
if (! $content) {
return;
}
$fullValue = $content->getTranslation('value', $locale);
if ($this->editingField !== null && is_array($fullValue)) {
$fieldValue = $fullValue[$this->editingField] ?? [];
if (is_array($fieldValue)) {
[$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fieldValue);
$this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
}
} elseif (is_array($fullValue)) {
[$this->jsonIsStringArray, $this->jsonItems] = _cmsParseJsonItems($fullValue);
$this->jsonItems = CmsFluxEditorHtmlTransformer::toEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
}
} elseif ($this->editingId) {
$content = CmsContent::find($this->editingId);
if (! $content) {
return;
}
$fullValue = $content->getTranslation('value', $locale);
if ($this->editingField !== null && is_array($fullValue)) {
$fieldValue = $fullValue[$this->editingField] ?? '';
$this->editValue = is_array($fieldValue)
? json_encode($fieldValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
: (string) $fieldValue;
if (! is_array($fieldValue)) {
if (_cmsFieldLooksLikeImage((string) $this->editingField, $fieldValue)) {
$this->editingFieldType = 'image';
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
} elseif ($content->group === 'legal' && $this->editingField === 'content') {
$this->editingFieldType = 'legal_html';
$this->editMediaId = null;
} else {
$this->editingFieldType = is_string($fieldValue) && preg_match('/<[^>]+>/', $fieldValue) ? 'html' : 'text';
$this->editMediaId = null;
}
if ($this->editingFieldType === 'html') {
$this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue);
}
}
} else {
$this->editValue = is_array($fullValue)
? json_encode($fullValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
: (string) ($fullValue ?? '');
if ($content->type === 'image') {
$this->editingFieldType = 'image';
$this->editMediaId = CmsMedia::where('filename', $this->editValue)->first()?->id;
} elseif ($content->type === 'html') {
$this->editingFieldType = 'html';
$this->editMediaId = null;
$this->editValue = CmsFluxEditorHtmlTransformer::toEditor($this->editValue);
} else {
$this->editingFieldType = 'text';
$this->editMediaId = null;
}
}
}
};
$saveEdit = function () {
$content = CmsContent::find($this->editingId);
if (! $content) {
return;
}
$valueToSave = $this->editValue;
if ($this->editingFieldType === 'html') {
$valueToSave = CmsFluxEditorHtmlTransformer::fromEditor($valueToSave);
}
if ($this->editingField !== null) {
$fullValue = $content->getTranslation('value', $this->editLocale);
if (! is_array($fullValue)) {
$fullValue = [];
}
$fullValue[$this->editingField] = $valueToSave;
$content->setTranslation('value', $this->editLocale, $fullValue);
} else {
$content->setTranslation('value', $this->editLocale, $valueToSave);
}
$content->save();
app(CmsContentService::class)->clearCache($this->selectedGroup);
$this->editingId = null;
$this->editingField = null;
$this->editValue = '';
$this->editingFieldType = 'text';
$this->editMediaId = null;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
};
$addJsonItem = function () {
if ($this->jsonIsStringArray) {
$this->jsonItems[] = ['_value' => ''];
} elseif (! empty($this->jsonItems)) {
$template = array_map(fn () => '', $this->jsonItems[0]);
$this->jsonItems[] = $template;
}
};
$removeJsonItem = function (int $index) {
unset($this->jsonItems[$index]);
$this->jsonItems = array_values($this->jsonItems);
};
$saveJsonModal = function () {
$content = CmsContent::find($this->editingId);
if (! $content) {
return;
}
$itemsForSave = CmsFluxEditorHtmlTransformer::fromEditorJsonItems($this->jsonItems, $this->jsonIsStringArray);
if ($this->jsonIsStringArray) {
$newValue = array_values(array_map(fn ($item) => $item['_value'] ?? '', $itemsForSave));
} else {
$newValue = array_values(
array_map(function ($item) {
$cleaned = [];
foreach ($item as $k => $v) {
if (is_string($v) && (str_starts_with($v, '[') || str_starts_with($v, '{'))) {
$decoded = json_decode($v, true);
$cleaned[$k] = json_last_error() === JSON_ERROR_NONE ? $decoded : $v;
} else {
$cleaned[$k] = $v;
}
}
return $cleaned;
}, $itemsForSave),
);
}
if ($this->editingField !== null) {
$fullValue = $content->getTranslation('value', $this->editLocale);
if (! is_array($fullValue)) {
$fullValue = [];
}
$fullValue[$this->editingField] = $newValue;
$content->setTranslation('value', $this->editLocale, $fullValue);
} else {
$content->setTranslation('value', $this->editLocale, $newValue);
}
$content->save();
app(CmsContentService::class)->clearCache($this->selectedGroup);
$this->showJsonModal = false;
$this->editingId = null;
$this->editingField = null;
$this->jsonItems = [];
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Inhalt wurde erfolgreich aktualisiert.');
};
$cancelEdit = function () {
$this->editingId = null;
$this->editingField = null;
$this->editValue = '';
$this->editingFieldType = 'text';
$this->editMediaId = null;
};
$cancelJsonModal = function () {
$this->showJsonModal = false;
$this->editingId = null;
$this->editingField = null;
$this->jsonItems = [];
};
?>
@php
$cmsGroupLabels = [
'legal' => 'Rechtliches',
];
@endphp
<div>
<div class="mb-6 flex items-center justify-between">
<flux:heading size="xl">Inhalte verwalten</flux:heading>
<div class="flex items-center gap-2">
<flux:badge color="blue">{{ array_sum($this->groups) }} Einträge</flux:badge>
</div>
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-4">
<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>{{ $cmsGroupLabels[$group] ?? $group }}</span>
<flux:badge size="sm">{{ $count }}</flux:badge>
</button>
@endforeach
</div>
</flux:card>
</div>
<div class="lg:col-span-3" wire:key="cms-content-panel-{{ $selectedGroup ?? 'empty' }}">
@if ($selectedGroup)
<flux:card>
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ $cmsGroupLabels[$selectedGroup] ?? $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>
@php $lastSection = null; @endphp
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->flatContents as $row)
@if ($row->section_key !== $lastSection)
@php $lastSection = $row->section_key; @endphp
<div class="bg-zinc-50 dark:bg-zinc-800/50 px-3 py-2">
<span class="text-xs font-bold uppercase tracking-wider text-zinc-500 dark:text-zinc-400">{{ $row->section_key }}</span>
</div>
@endif
@php
$isEditing = $editingId === $row->content_id
&& $editingField === $row->field_key
&& ! $showJsonModal;
$editKey = $row->field_key !== null
? $row->content_id . ",'" . $row->field_key . "'"
: $row->content_id . ',null';
@endphp
<div wire:key="row-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}" class="py-3 px-1">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2">
<code class="rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400">{{ $row->field_key ?? $row->section_key }}</code>
@php
$typeColors = ['html' => 'amber', 'legal_html' => 'blue', 'image' => 'green', 'json' => 'violet', 'link' => 'rose'];
$badgeColor = $typeColors[$row->type] ?? 'zinc';
@endphp
<flux:badge size="sm" :color="$badgeColor">{{ $row->type }}</flux:badge>
</div>
<div
wire:key="field-value-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}-{{ $isEditing ? 'edit' : 'view' }}"
>
@if ($isEditing)
<div class="mt-2">
@if ($editingFieldType === 'image')
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
@if ($editValue)
<div class="h-24 w-36 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<img src="{{ media_url($editValue) }}" alt="" class="h-full w-full object-cover" />
</div>
@endif
<div class="min-w-0 flex-1 space-y-2">
<livewire:admin.cms.media-picker
:value="$editMediaId"
field="content_image"
type="image"
profile="card"
label="Bild aus Medienbibliothek wählen"
:key="'cms-content-img-' . $selectedGroup . '-' . $editingId . '-' . ($editingField ?? 'root') . '-' . $editLocale"
/>
<flux:input wire:model="editValue" size="sm" label="Dateiname (optional manuell)" placeholder="z. B. b2in/hero.jpg" />
</div>
</div>
@elseif ($editingFieldType === 'html')
<flux:editor wire:model="editValue" toolbar="bold italic highlight"
class="**:data-[slot=content]:min-h-[60px]!" />
@elseif ($editingFieldType === 'legal_html')
{{-- natives textarea, Inhalt nur per Livewire-JS (kein </textarea> im HTML); verhindert DOM-Bruch bei Impressum-HTML --}}
<div class="space-y-2">
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300" for="legal-html-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}">HTML (Rohtext)</label>
<textarea
id="legal-html-{{ $row->content_id }}-{{ $row->field_key ?? 'root' }}"
wire:model.defer="editValue"
rows="28"
spellcheck="false"
class="block min-h-[24rem] w-full resize-y rounded-lg border border-zinc-200 bg-white p-3 font-mono text-xs text-zinc-800 shadow-xs dark:border-white/10 dark:bg-white/10 dark:text-zinc-200"
></textarea>
</div>
@else
<flux:textarea wire:model="editValue" rows="8" class="font-mono text-sm" />
@endif
<div class="mt-2 flex gap-2">
<flux:button size="sm" variant="primary" wire:click="saveEdit" wire:loading.attr="disabled" wire:target="saveEdit">
<span wire:loading.remove wire:target="saveEdit">Speichern</span>
<span wire:loading wire:target="saveEdit">Speichern…</span>
</flux:button>
<flux:button size="sm" variant="ghost" wire:click="cancelEdit" wire:loading.attr="disabled" wire:target="saveEdit">Abbrechen</flux:button>
</div>
</div>
@else
@if ($row->type === 'json' && is_array($row->value))
@php
$isList = array_is_list($row->value);
$cnt = count($row->value);
@endphp
<div class="flex items-center gap-2 text-sm text-zinc-500">
<flux:badge size="sm" color="violet">{{ $cnt }} {{ $isList ? 'Einträge' : 'Felder' }}</flux:badge>
@if ($isList && $cnt > 0 && is_array($row->value[0] ?? null))
<span class="text-xs text-zinc-400">{{ implode(', ', array_keys($row->value[0])) }}</span>
@elseif (! $isList)
<span class="text-xs text-zinc-400">{{ implode(', ', array_slice(array_keys($row->value), 0, 5)) }}{{ $cnt > 5 ? ', …' : '' }}</span>
@endif
</div>
@elseif ($row->type === 'image' && is_string($row->value) && $row->value !== '')
<div class="flex items-center gap-3">
<div class="h-14 w-24 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<img src="{{ media_url($row->value) }}" alt="" class="h-full w-full object-cover" loading="lazy" />
</div>
<span class="truncate text-xs text-zinc-500">{{ $row->value }}</span>
</div>
@elseif ($row->type === 'legal_html')
<p class="line-clamp-3 text-sm text-zinc-600 dark:text-zinc-400">
{{ \Illuminate\Support\Str::limit(strip_tags((string) $row->value), 200) }}
</p>
@elseif ($row->type === 'html')
<div class="prose prose-sm max-w-none text-zinc-800 dark:prose-invert dark:text-zinc-200 line-clamp-2">
{!! \Illuminate\Support\Str::limit((string) $row->value, 200) !!}
</div>
@else
<p class="truncate text-sm text-zinc-800 dark:text-zinc-200">
{{ \Illuminate\Support\Str::limit(strip_tags(is_array($row->value) ? json_encode($row->value, JSON_UNESCAPED_UNICODE) : (string) ($row->value ?? '')), 120) }}
</p>
@endif
@endif
</div>
</div>
@if (! $isEditing)
<flux:button size="sm" variant="ghost" icon="pencil"
wire:click="startFieldEdit({{ $editKey }})" />
@endif
</div>
</div>
@empty
<flux:text class="py-8 text-center">Keine Einträge gefunden.</flux:text>
@endforelse
</div>
</flux:card>
@else
<flux:card>
<div class="py-12 text-center">
<flux:icon name="document-text" class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
<flux:heading>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: kein flux:modal vermeidet ReferenceError fluxModal bei jedem Livewire-Render (z. B. bei legal_html). --}}
@if ($showJsonModal)
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6"
wire:key="cms-json-editor-overlay-{{ $selectedGroup ?? 'none' }}"
x-data
x-on:keydown.escape.window.prevent="$wire.cancelJsonModal()"
>
<div class="fixed inset-0 bg-zinc-950/50 backdrop-blur-sm" wire:click="cancelJsonModal" aria-hidden="true">
</div>
<div
role="dialog"
aria-modal="true"
class="relative z-10 flex w-full max-w-5xl max-h-[90vh] flex-col overflow-y-auto space-y-6 rounded-xl border border-zinc-200 bg-white p-6 shadow-xl dark:border-zinc-700 dark:bg-zinc-800"
wire:click.stop
>
<div>
<flux:heading size="lg">{{ $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']);
$isNestedJson = is_string($fieldValue) && (str_starts_with($fieldValue, '[') || str_starts_with($fieldValue, '{'));
$isImageField = _cmsFieldLooksLikeImage($field, $fieldValue);
$jsonImageMediaId = is_string($fieldValue) && $fieldValue !== '' ? \FluxCms\Core\Models\CmsMedia::where('filename', $fieldValue)->first()?->id : null;
@endphp
@if ($isIcon)
<div class="md:col-span-2">
<div class="flex items-end gap-3">
<div class="flex-1">
<flux:select wire:model="jsonItems.{{ $idx }}.{{ $field }}"
variant="listbox" searchable label="{{ 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 ($isImageField)
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">{{ ucfirst($field) }}</label>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
@if (is_string($fieldValue) && $fieldValue !== '')
<div class="h-20 w-32 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<img src="{{ media_url($fieldValue) }}" alt="" class="h-full w-full object-cover" />
</div>
@endif
<div class="min-w-0 flex-1 space-y-2">
<livewire:admin.cms.media-picker
:value="$jsonImageMediaId"
:field="'jsonimg:' . $idx . ':' . $field"
type="image"
profile="card"
label="Bild wählen"
:key="'json-img-' . ($selectedGroup ?? '') . '-' . $jsonEditingKey . '-' . $idx . '-' . $field . '-' . $editLocale"
/>
<flux:input wire:model="jsonItems.{{ $idx }}.{{ $field }}" size="sm" placeholder="Dateiname (optional manuell)" />
</div>
</div>
</div>
@elseif ($isRichText)
<div class="md:col-span-2">
<flux:editor wire:model="jsonItems.{{ $idx }}.{{ $field }}"
label="{{ ucfirst($field) }}" toolbar="bold italic highlight"
class="**:data-[slot=content]:min-h-[60px]!" />
</div>
@elseif ($isNestedJson)
<div class="md:col-span-2">
<flux:textarea wire:model="jsonItems.{{ $idx }}.{{ $field }}"
label="{{ 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>
</div>
</div>
@endif
</div>

View file

@ -0,0 +1,156 @@
<?php
use App\Models\CmsArticle;
use App\Models\CmsProject;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Models\CmsMedia;
use function Livewire\Volt\{layout, title, computed};
layout('components.layouts.app');
title('CMS Dashboard');
$stats = computed(fn () => [
'contents' => CmsContent::count(),
'groups' => CmsContent::distinct()->pluck('group')->count(),
'projects' => CmsProject::count(),
'projects_published' => CmsProject::published()->count(),
'articles' => CmsArticle::count(),
'articles_published' => CmsArticle::published()->count(),
'media' => CmsMedia::count(),
]);
?>
<div>
<div class="mb-6">
<flux:heading size="xl">CMS</flux:heading>
<flux:text class="mt-1">Inhalte, Projekte und Medien verwalten.</flux:text>
</div>
<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.projects.index') }}" wire:navigate>
<flux:card class="hover:border-emerald-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="building-office" class="text-emerald-500" />
<div>
<flux:heading size="lg">{{ $this->stats['projects'] }}</flux:heading>
<flux:text class="text-sm">Projekte ({{ $this->stats['projects_published'] }} veröffentlicht)</flux:text>
</div>
</div>
</flux:card>
</a>
<a href="{{ route('cms.articles.index') }}" wire:navigate>
<flux:card class="hover:border-amber-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="newspaper" class="text-amber-500" />
<div>
<flux:heading size="lg">{{ $this->stats['articles'] }}</flux:heading>
<flux:text class="text-sm">Magazin-Artikel ({{ $this->stats['articles_published'] }} veröffentlicht)</flux:text>
</div>
</div>
</flux:card>
</a>
<a href="{{ route('cms.media.index') }}" wire:navigate>
<flux:card class="hover:border-violet-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="photo" class="text-violet-500" />
<div>
<flux:heading size="lg">{{ $this->stats['media'] }}</flux:heading>
<flux:text class="text-sm">Medien</flux:text>
</div>
</div>
</flux:card>
</a>
</div>
<flux:card class="mt-6 max-w-4xl">
<flux:heading size="lg" class="mb-4">So funktioniert das CMS</flux:heading>
<div class="space-y-5 text-sm text-zinc-600 dark:text-zinc-400">
<div>
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
<flux:icon name="squares-2x2" class="size-4 text-zinc-500" />
Aufbau & Navigation
</flux:heading>
<p>
Das CMS ist die zentrale Stelle, um alle Inhalte der öffentlichen Website zu pflegen.
Es gliedert sich in vier Module, die Sie über die Kacheln oben erreichen:
</p>
<ul class="mt-2 ml-5 list-disc space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Inhalte</strong> Texte, Abschnitte und strukturierte Daten der Website (z.&nbsp;B. Startseite, Netzwerk, rechtliche Texte). Inhalte sind in <em>Gruppen</em> organisiert, sodass zusammengehörige Felder gebündelt bearbeitet werden können.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Projekte</strong> Immobilien-Referenzobjekte mit Bildern, Preisen und Beschreibungen. Jedes Projekt kann als Entwurf angelegt und später veröffentlicht werden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Magazin</strong> Redaktionelle Artikel (z.&nbsp;B. über Dubai-Immobilien, Supply-Chain oder Einrichtung). Artikel unterstützen Kategorien, Autoren und Lesezeiten.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medien</strong> Zentrale Bild- und Dateiverwaltung. Alle Uploads landen hier und können in jedem Modul wiederverwendet werden.</li>
</ul>
</div>
<flux:separator />
<div>
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
Medienbibliothek & Bildfelder
</flux:heading>
<p>
Jede Datei, die auf der Website erscheint, wird in der <strong class="font-medium text-zinc-800 dark:text-zinc-200">Medienbibliothek</strong> verwaltet.
Sie haben zwei Wege, Dateien hochzuladen:
</p>
<ol class="mt-2 ml-5 list-decimal space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Vorab</strong> Im Modul „Medien" Dateien hochladen, mit Titel und Alt-Text versehen und organisieren.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Im Kontext</strong> Beim Bearbeiten eines Inhalts, Projekts oder Artikels auf „Bild auswählen" klicken. Im Auswahldialog können Sie ein bestehendes Medium wählen <em>oder</em> per Drag-and-drop direkt hochladen. Dieser Schnell-Upload wird automatisch in der Medienbibliothek gespeichert.</li>
</ol>
<p class="mt-2">
Egal welchen Weg Sie wählen: Es gibt immer einen zentralen Bibliothekseintrag. So behalten Sie den Überblick und können dasselbe Bild an mehreren Stellen einsetzen.
</p>
</div>
<flux:separator />
<div>
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
<flux:icon name="document-text" class="size-4 text-blue-500" />
Inhalte bearbeiten
</flux:heading>
<p>
Inhalte sind nach <strong class="font-medium text-zinc-800 dark:text-zinc-200">Gruppen</strong> geordnet z.&nbsp;B. <em>homepage</em>, <em>netzwerk</em>, <em>legal</em>.
Innerhalb einer Gruppe sehen Sie die einzelnen Felder (Texte, Rich-Text, Bilder oder JSON-Strukturen).
</p>
<ul class="mt-2 ml-5 list-disc space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Mehrsprachigkeit:</strong> Wo mehrere Sprachen vorgesehen sind, wählen Sie die gewünschte Sprache (DE / EN) im Editor. Jede Sprachversion wird einzeln gespeichert.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Sofort live:</strong> Gespeicherte Änderungen erscheinen auf der öffentlichen Website, sobald die jeweilige Seite den entsprechenden CMS-Key einbindet ein zusätzliches „Veröffentlichen" ist nicht nötig.</li>
</ul>
</div>
<flux:separator />
<div>
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
<flux:icon name="building-office" class="size-4 text-emerald-500" />
Projekte & Magazin verwalten
</flux:heading>
<p>
Projekte und Magazin-Artikel sind eigenständige Einträge mit eigenem Lebenszyklus:
</p>
<ul class="mt-2 ml-5 list-disc space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Entwurf / Veröffentlicht:</strong> Neue Einträge starten als Entwurf. Erst nach dem Veröffentlichen sind sie auf der Website sichtbar. Sie können Einträge jederzeit wieder auf „Entwurf" setzen, um sie temporär auszublenden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Reihenfolge:</strong> Über das Sortierfeld steuern Sie, in welcher Reihenfolge Projekte und Artikel auf der Website erscheinen (z.&nbsp;B. Portfolio-Listen, Magazin-Übersicht).</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Bilder:</strong> Projekt- und Artikelbilder werden über die Medienauswahl zugeordnet (siehe oben).</li>
</ul>
</div>
</div>
</flux:card>
</div>

View file

@ -0,0 +1,294 @@
<?php
use App\Enums\DisplayVersionType;
use App\Models\CabinetTabletSetting;
use App\Models\Display;
use App\Models\DisplayMedia;
use App\Models\DisplayVersion;
use App\Models\DisplayVersionItem;
use function Livewire\Volt\{layout, title, computed};
layout('components.layouts.app');
title('Store Displays');
$stats = computed(fn () => [
'displays' => Display::count(),
'displays_active' => Display::where('is_active', true)->count(),
'versions' => DisplayVersion::count(),
'versions_active' => DisplayVersion::active()->count(),
'items' => DisplayVersionItem::count(),
'items_active' => DisplayVersionItem::where('is_active', true)->count(),
'type_video' => DisplayVersion::ofType(DisplayVersionType::VideoDisplay)->count(),
'type_b2in' => DisplayVersion::ofType(DisplayVersionType::B2in)->count(),
'type_offers' => DisplayVersion::ofType(DisplayVersionType::Offers)->count(),
'media_total' => DisplayMedia::count(),
'media_uploads' => DisplayMedia::uploads()->count(),
'media_externals' => DisplayMedia::externals()->count(),
]);
$tabletStatus = computed(function () {
try {
$settings = CabinetTabletSetting::current();
return $settings->computeStatus()['status'];
} catch (\Throwable) {
return null;
}
});
?>
<div>
<div class="mb-6">
<flux:heading size="xl">Store Displays</flux:heading>
<flux:text class="mt-1">Displays, Inhalts-Versionen und Info-Tablet im Cabinet Showroom verwalten.</flux:text>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<a href="{{ route('admin.cms.display-media') }}" wire:navigate>
<flux:card class="hover:border-violet-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="photo" class="text-violet-500" />
<div>
<flux:heading size="lg">{{ $this->stats['media_total'] }}</flux:heading>
<flux:text class="text-sm">Medien ({{ $this->stats['media_uploads'] }} Uploads, {{ $this->stats['media_externals'] }} extern)</flux:text>
</div>
</div>
</flux:card>
</a>
<a href="{{ route('admin.cms.display-versions') }}" wire:navigate>
<flux:card class="hover:border-purple-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="rectangle-group" class="text-purple-500" />
<div>
<flux:heading size="lg">{{ $this->stats['versions'] }}</flux:heading>
<flux:text class="text-sm">Versionen ({{ $this->stats['versions_active'] }} aktiv)</flux:text>
</div>
</div>
</flux:card>
</a>
<a href="{{ route('admin.cms.displays') }}" wire:navigate>
<flux:card class="hover:border-blue-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="tv" class="text-blue-500" />
<div>
<flux:heading size="lg">{{ $this->stats['displays'] }}</flux:heading>
<flux:text class="text-sm">Displays ({{ $this->stats['displays_active'] }} aktiv)</flux:text>
</div>
</div>
</flux:card>
</a>
<a href="{{ route('admin.cms.cabinet-tablet') }}" wire:navigate>
<flux:card class="hover:border-teal-500 transition-colors">
<div class="flex items-center gap-3">
<flux:icon name="device-tablet" class="text-teal-500" />
<div>
<flux:heading size="lg">Info-Tablet</flux:heading>
<flux:text class="text-sm">
@if($this->tabletStatus)
Status:
<flux:badge size="sm" :color="match($this->tabletStatus) {
'open' => 'green',
'closed' => 'red',
'notice' => 'amber',
'warning' => 'orange',
default => 'zinc',
}">
{{ match($this->tabletStatus) {
'open' => 'Geöffnet',
'closed' => 'Geschlossen',
'notice' => 'Hinweis',
'warning' => 'Warnung',
default => $this->tabletStatus,
} }}
</flux:badge>
@else
Öffnungszeiten & Status
@endif
</flux:text>
</div>
</div>
</flux:card>
</a>
<flux:card>
<div class="flex items-center gap-3">
<flux:icon name="queue-list" class="text-zinc-400" />
<div>
<flux:heading size="lg">{{ $this->stats['items'] }}</flux:heading>
<flux:text class="text-sm">Inhalte gesamt ({{ $this->stats['items_active'] }} aktiv)</flux:text>
</div>
</div>
</flux:card>
</div>
{{-- Versions-Typen Übersicht --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3 mt-4">
<flux:card>
<div class="flex items-center gap-3">
<flux:icon name="film" class="text-purple-400" />
<div>
<flux:text class="text-sm font-medium text-zinc-800 dark:text-zinc-200">Video-Display</flux:text>
<flux:text class="text-xs">{{ $this->stats['type_video'] }} {{ $this->stats['type_video'] === 1 ? 'Version' : 'Versionen' }}</flux:text>
</div>
</div>
</flux:card>
<flux:card>
<div class="flex items-center gap-3">
<flux:icon name="photo" class="text-blue-400" />
<div>
<flux:text class="text-sm font-medium text-zinc-800 dark:text-zinc-200">B2in Display</flux:text>
<flux:text class="text-xs">{{ $this->stats['type_b2in'] }} {{ $this->stats['type_b2in'] === 1 ? 'Version' : 'Versionen' }}</flux:text>
</div>
</div>
</flux:card>
<flux:card>
<div class="flex items-center gap-3">
<flux:icon name="tag" class="text-amber-400" />
<div>
<flux:text class="text-sm font-medium text-zinc-800 dark:text-zinc-200">Angebote</flux:text>
<flux:text class="text-xs">{{ $this->stats['type_offers'] }} {{ $this->stats['type_offers'] === 1 ? 'Version' : 'Versionen' }}</flux:text>
</div>
</div>
</flux:card>
</div>
{{-- Beschreibung --}}
<flux:card class="mt-6 max-w-4xl">
<flux:heading size="lg" class="mb-4">So funktioniert das Display-System</flux:heading>
<div class="space-y-5 text-sm text-zinc-600 dark:text-zinc-400">
<div>
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
<flux:icon name="squares-2x2" class="size-4 text-zinc-500" />
Überblick
</flux:heading>
<p>
Das Display-System steuert alle Bildschirme im Cabinet Showroom Bielefeld.
Es besteht aus drei Bereichen, die Sie über die Kacheln oben erreichen:
</p>
<ul class="mt-2 ml-5 list-disc space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Mediathek</strong> Zentrale Verwaltung aller Bilder und Videos fuer die Displays. Dateien bis 50 MB direkt hochladen oder groessere Videos als externe URL (Google Drive, OneDrive) einbinden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Versionen</strong> Content-Pakete, die auf den Displays abgespielt werden. Jede Version hat einen bestimmten Typ und enthält passende Inhalte (Videos, Bilder oder Angebots-Slides).</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Displays</strong> Die physischen Bildschirme im Showroom. Jedem Display werden eine oder mehrere Versionen als Playlist zugewiesen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Info-Tablet</strong> Das Tablet an der Eingangstür des Showrooms. Hier verwalten Sie Öffnungszeiten, den aktuellen Store-Status und Hinweise für Besucher.</li>
</ul>
</div>
<flux:separator />
<div>
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
<flux:icon name="photo" class="size-4 text-violet-500" />
Mediathek
</flux:heading>
<p>
Die <strong class="font-medium text-zinc-800 dark:text-zinc-200">Display-Mediathek</strong> verwaltet alle Bilder und Videos, die auf den Displays im Showroom angezeigt werden.
Sie ist unabhängig von der Website-Mediathek (Flux CMS) und speziell auf die Anforderungen der Displays zugeschnitten.
</p>
<ul class="mt-2 ml-5 list-disc space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Direkt-Upload:</strong> Bilder und Videos bis 50 MB direkt per Drag-and-drop oder Dateiauswahl hochladen. Die Dateien werden auf dem Server gespeichert und stehen sofort zur Verfügung.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Externe URLs:</strong> Für Videos über 50 MB (z.&nbsp;B. 4K-Showroom-Rundgänge) können Sie einen Freigabe-Link von Google Drive, OneDrive oder anderen Cloud-Diensten hinterlegen. Diese URL wird wie ein normales Medium in der Mediathek verwaltet und kann genauso in Versionen eingebunden werden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Sammlungen:</strong> Ordnen Sie Medien in Sammlungen wie <em>immobilien</em>, <em>moebel</em> oder <em>brand</em>, um bei vielen Dateien den Überblick zu behalten.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medienauswahl im Editor:</strong> Beim Bearbeiten einer Version erscheint ein „Aus Mediathek"-Button. Darüber öffnen Sie die Medienauswahl und können bestehende Medien wählen oder direkt neue hochladen.</li>
</ul>
</div>
<flux:separator />
<div>
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
<flux:icon name="rectangle-group" class="size-4 text-purple-500" />
Versionen & Versions-Typen
</flux:heading>
<p>
Eine <strong class="font-medium text-zinc-800 dark:text-zinc-200">Version</strong> ist ein Content-Paket mit einem bestimmten Typ.
Der Typ bestimmt, welche Art von Inhalten hinzugefügt werden können:
</p>
<ul class="mt-2 ml-5 list-disc space-y-1">
<li>
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Video-Display</strong>
Für Video-Playlists mit optionalem Footer. Inhalte: <em>Videos</em> (Dateiname, Titel, Position/Ausschnitt) und <em>Footer-Zeilen</em> (Überschrift, Unterzeile, optionaler QR-Code-Link).
</li>
<li>
<strong class="font-medium text-zinc-800 dark:text-zinc-200">B2in Display</strong>
Für Medien-Rotation (Bilder und Videos) im Marken-Design. Inhalte: <em>Media-Items</em> mit Kategorie (Immobilien / Möbel), Überschrift, Unterzeile und Anzeigedauer. Unterstützt Light-/Dark-Theme.
</li>
<li>
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Angebote</strong>
Für Produkt-Slides im Angebotsformat. Verschiedene Slide-Layouts: Intro, Produkt-Hero, Produkt-Details und Impuls-Slides mit Preisen, Badges und QR-Codes.
</li>
</ul>
<p class="mt-2">
Innerhalb einer Version können Sie beliebig viele Inhalte anlegen, die Reihenfolge per Hoch/Runter-Sortierung festlegen und einzelne Einträge aktivieren oder deaktivieren.
</p>
</div>
<flux:separator />
<div>
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
<flux:icon name="tv" class="size-4 text-blue-500" />
Displays & Playlists
</flux:heading>
<p>
Ein <strong class="font-medium text-zinc-800 dark:text-zinc-200">Display</strong> repräsentiert einen physischen Bildschirm im Showroom.
</p>
<ul class="mt-2 ml-5 list-disc space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Versions-Zuweisung:</strong> Jedem Display können Sie eine oder mehrere Versionen zuordnen. Die Versionen werden in der festgelegten Reihenfolge als Playlist abgespielt.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Aktiv/Inaktiv:</strong> Über den Aktiv-Status können Sie einzelne Displays vorübergehend deaktivieren, ohne die Konfiguration zu verlieren.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">API-Anbindung:</strong> Jedes Display ruft seine Inhalte über eine JSON-API ab (<code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-1 py-0.5 rounded">/api/display/{id}/config</code>). Änderungen werden beim nächsten Abruf automatisch übernommen.</li>
</ul>
</div>
<flux:separator />
<div>
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
<flux:icon name="device-tablet" class="size-4 text-teal-500" />
Info-Tablet
</flux:heading>
<p>
Das Info-Tablet zeigt Besuchern am Showroom-Eingang den aktuellen Status und die Öffnungszeiten.
</p>
<ul class="mt-2 ml-5 list-disc space-y-1">
<li>
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Store-Status:</strong>
Vier Modi stehen zur Verfügung <em>Automatisch</em> (berechnet den Status aus den Öffnungszeiten), <em>Geschlossen</em> (manuell), <em>Hinweis</em> (eigene Nachricht) und <em>Warnung</em> (dringende Nachricht).
</li>
<li>
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Öffnungszeiten:</strong>
Für jeden Wochentag (MontagSonntag) können individuelle Öffnungs- und Schließzeiten gepflegt werden. Tage ohne Zeiten gelten als geschlossen.
</li>
<li>
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Tages-Overrides:</strong>
Für Sonderfälle (z.&nbsp;B. früher schließen) können Sie die Zeiten für den heutigen Tag überschreiben. Diese Überschreibungen werden automatisch um Mitternacht zurückgesetzt.
</li>
<li>
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Kontaktdaten & Termine:</strong>
Telefonnummer, E-Mail-Adresse und der nächste Termin werden auf dem Tablet angezeigt und können hier zentral gepflegt werden.
</li>
</ul>
</div>
<flux:separator />
<div>
<flux:heading size="sm" class="mb-1 flex items-center gap-2">
<flux:icon name="arrow-path" class="size-4 text-zinc-500" />
Typischer Workflow
</flux:heading>
<ol class="mt-2 ml-5 list-decimal space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Version erstellen</strong> Unter „Versionen" eine neue Version mit passendem Typ anlegen (z.&nbsp;B. „Frühling 2026 Video").</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Inhalte hinzufügen</strong> In der Version Videos, Medien oder Slides anlegen, Reihenfolge festlegen und aktivieren.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Display zuweisen</strong> Unter „Displays" die Version einem physischen Bildschirm zuordnen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Fertig</strong> Das Display lädt die neuen Inhalte automatisch über die API.</li>
</ol>
</div>
</div>
</flux:card>
</div>

View file

@ -0,0 +1,204 @@
<div>
<flux:header class="mb-6">
<flux:heading size="xl">{{ __('Displays') }}</flux:heading>
<flux:subheading>{{ __('Verwalten Sie Ihre physischen Displays und weisen Sie ihnen Versionen zu') }}</flux:subheading>
</flux:header>
@if (session()->has('success'))
<x-success-alert>
{{ session('success') }}
</x-success-alert>
@endif
<flux:card>
<div class="flex items-center justify-between mb-6">
<div>
<flux:heading size="lg">{{ __('Physische Displays') }}</flux:heading>
<flux:subheading>{{ __('Jedem Display können mehrere Versionen als Playlist zugewiesen werden') }}</flux:subheading>
</div>
<flux:button wire:click="openModal" icon="plus" variant="primary">
{{ __('Display hinzufügen') }}
</flux:button>
</div>
@if($displays->isEmpty())
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
<flux:icon.tv class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>{{ __('Noch keine Displays vorhanden. Fügen Sie Ihr erstes Display hinzu!') }}</p>
</div>
@else
<div class="space-y-3">
@foreach($displays as $display)
<div wire:key="display-{{ $display->id }}"
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600 transition">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1">
<flux:badge :color="$display->is_active ? 'green' : 'zinc'" size="sm">
{{ $display->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
<span class="font-semibold text-sm">{{ $display->name }}</span>
@if($display->location)
<span class="text-xs text-zinc-500 dark:text-zinc-400">{{ $display->location }}</span>
@endif
</div>
@if($display->versions->isNotEmpty())
<div class="flex flex-wrap items-center gap-1.5 mt-2">
@foreach($display->versions as $idx => $version)
@if($idx > 0)
<flux:icon.chevron-right class="w-3 h-3 text-zinc-400" />
@endif
<flux:badge color="{{ match($version->type->value) {
'video-display' => 'purple',
'b2in' => 'blue',
'offers' => 'amber',
} }}" size="sm">
{{ $version->name }}
</flux:badge>
@endforeach
</div>
@else
<div class="text-xs text-amber-600 dark:text-amber-400 mt-1">
{{ __('Keine Versionen zugewiesen') }}
</div>
@endif
{{-- Direct display links --}}
<div class="mt-2 flex items-center gap-4">
<a href="/_cabinet/display/index.html?id={{ $display->id }}"
target="_blank"
class="text-xs text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1">
<flux:icon.play class="w-3 h-3" />
Display öffnen
</a>
<a href="/api/display/{{ $display->id }}/config"
target="_blank"
class="text-xs text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 hover:underline inline-flex items-center gap-1">
<flux:icon.code-bracket class="w-3 h-3" />
API
</a>
</div>
</div>
<div class="flex items-center gap-2">
<flux:button wire:click="toggleActive({{ $display->id }})"
size="sm"
variant="ghost"
:icon="$display->is_active ? 'eye-slash' : 'eye'">
</flux:button>
<flux:button wire:click="openModal({{ $display->id }})"
size="sm"
variant="ghost"
icon="pencil">
</flux:button>
<flux:button wire:click="deleteDisplay({{ $display->id }})"
wire:confirm="Möchten Sie dieses Display wirklich löschen?"
size="sm"
variant="ghost"
icon="trash"
class="text-red-600 hover:text-red-700">
</flux:button>
</div>
</div>
@endforeach
</div>
@endif
</flux:card>
{{-- Display Modal --}}
<flux:modal :open="$showModal" wire:model="showModal">
<form wire:submit.prevent="save">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ $displayId ? __('Display bearbeiten') : __('Display hinzufügen') }}</flux:heading>
</div>
<flux:input wire:model="displayName" label="Name" placeholder="z.B. Display 1 - Eingang" />
@error('displayName') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:input wire:model="displayLocation" label="Standort (optional)" placeholder="z.B. Schaufenster links" />
{{-- Version Playlist --}}
<div>
<flux:heading size="sm" class="mb-2">{{ __('Versions-Playlist') }}</flux:heading>
<flux:subheading class="mb-3">{{ __('Versionen werden in dieser Reihenfolge als Schleife abgespielt') }}</flux:subheading>
@if(count($selectedVersionIds) > 0)
<div class="space-y-2 mb-3">
@foreach($selectedVersionIds as $index => $versionId)
@php $ver = $versions->firstWhere('id', $versionId); @endphp
@if($ver)
<div wire:key="playlist-{{ $index }}-{{ $versionId }}"
class="flex items-center gap-2 p-2 bg-zinc-100 dark:bg-zinc-700 rounded border border-zinc-200 dark:border-zinc-600">
<span class="text-xs text-zinc-400 font-mono w-5 text-center">{{ $index + 1 }}</span>
<flux:badge color="{{ match($ver->type->value) {
'video-display' => 'purple',
'b2in' => 'blue',
'offers' => 'amber',
} }}" size="sm">
{{ $ver->type->label() }}
</flux:badge>
<span class="text-sm flex-1">{{ $ver->name }}</span>
<div class="flex items-center gap-1">
<flux:button wire:click="moveVersion({{ $index }}, 'up')"
size="xs"
variant="ghost"
icon="chevron-up"
:disabled="$index === 0">
</flux:button>
<flux:button wire:click="moveVersion({{ $index }}, 'down')"
size="xs"
variant="ghost"
icon="chevron-down"
:disabled="$index === count($selectedVersionIds) - 1">
</flux:button>
<flux:button wire:click="removeVersion({{ $index }})"
size="xs"
variant="ghost"
icon="x-mark"
class="text-red-500">
</flux:button>
</div>
</div>
@endif
@endforeach
</div>
@else
<div class="text-center py-4 text-zinc-400 text-sm border border-dashed border-zinc-300 dark:border-zinc-600 rounded mb-3">
{{ __('Noch keine Versionen hinzugefügt') }}
</div>
@endif
<div class="flex gap-2">
<div class="flex-1">
<flux:select wire:model="addVersionSelect" placeholder="Version hinzufügen...">
@foreach($versions as $version)
<option value="{{ $version->id }}">{{ $version->name }} ({{ $version->type->label() }})</option>
@endforeach
</flux:select>
</div>
<flux:button wire:click="addVersion"
icon="plus"
size="sm"
variant="ghost">
</flux:button>
</div>
</div>
<flux:checkbox wire:model="displayIsActive" label="Display aktiv" />
<div class="flex justify-end gap-3 pt-4">
<flux:button type="button" wire:click="closeModal" variant="ghost">
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ $displayId ? __('Aktualisieren') : __('Hinzufügen') }}
</flux:button>
</div>
</div>
</form>
</flux:modal>
</div>

View file

@ -0,0 +1,541 @@
<?php
use App\Models\DisplayMedia;
use App\Services\DisplayMediaService;
use Flux\Flux;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use function Livewire\Volt\{layout, title, state, computed, on, uses};
layout('components.layouts.app');
title('Display-Mediathek');
uses([WithFileUploads::class]);
state([
'search' => '',
'filterType' => 'all',
'filterSource' => 'all',
'filterCollection' => '',
'viewMode' => 'grid',
'editingId' => null,
'editTitle' => '',
'editAltText' => '',
'editCollection' => '',
'showDetail' => false,
// Upload
'uploads' => [],
// External URL form
'showUrlModal' => false,
'urlInput' => '',
'urlType' => 'video',
'urlTitle' => '',
'urlCollection' => '',
'urlValidated' => null,
]);
$media = computed(
fn () => DisplayMedia::query()
->when($this->filterType !== 'all', fn ($q) => match ($this->filterType) {
'image' => $q->images(),
'video' => $q->videos(),
default => $q,
})
->when($this->filterSource !== 'all', fn ($q) => match ($this->filterSource) {
'upload' => $q->uploads(),
'external' => $q->externals(),
default => $q,
})
->when($this->filterCollection, fn ($q) => $q->inCollection($this->filterCollection))
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%")
->orWhere('title', 'like', "%{$this->search}%"))
->orderByDesc('created_at')
->paginate(48),
);
$collections = computed(fn () => DisplayMedia::query()
->whereNotNull('collection')
->where('collection', '!=', '')
->distinct()
->pluck('collection')
->sort()
->values()
->toArray());
$stats = computed(fn () => [
'total' => DisplayMedia::count(),
'images' => DisplayMedia::images()->count(),
'videos' => DisplayMedia::videos()->count(),
'uploads' => DisplayMedia::uploads()->count(),
'externals' => DisplayMedia::externals()->count(),
]);
// ========================================
// FILE UPLOAD
// ========================================
$handleUploads = function () {
$this->validate([
'uploads' => 'nullable|array|max:10',
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,mp4,webm,mov|max:51200',
]);
$service = app(DisplayMediaService::class);
$count = 0;
foreach ($this->uploads as $file) {
$service->storeUpload($file);
$count++;
}
$this->uploads = [];
if ($count > 0) {
Flux::toast(variant: 'success', heading: 'Hochgeladen', text: "{$count} Datei(en) erfolgreich hochgeladen.");
}
};
// ========================================
// EXTERNAL URL
// ========================================
$openUrlModal = function () {
$this->urlInput = '';
$this->urlType = 'video';
$this->urlTitle = '';
$this->urlCollection = '';
$this->urlValidated = null;
$this->showUrlModal = true;
};
$validateUrl = function () {
$this->validate(['urlInput' => 'required|url|max:2048']);
$service = app(DisplayMediaService::class);
$this->urlValidated = $service->validateExternalUrl($this->urlInput);
};
$saveExternalUrl = function () {
$this->validate([
'urlInput' => 'required|url|max:2048',
'urlType' => 'required|in:image,video',
'urlTitle' => 'nullable|string|max:255',
]);
$service = app(DisplayMediaService::class);
$service->createFromUrl(
url: $this->urlInput,
type: $this->urlType,
title: $this->urlTitle ?: null,
collection: $this->urlCollection ?: null,
);
$this->showUrlModal = false;
Flux::toast(variant: 'success', heading: 'Externe URL angelegt', text: 'Das Medium wurde als externe Referenz gespeichert.');
};
// ========================================
// DETAIL / EDIT
// ========================================
$startEdit = function (int $id) {
$media = DisplayMedia::find($id);
if (! $media) {
return;
}
$this->editingId = $id;
$this->editTitle = $media->title ?? '';
$this->editAltText = $media->alt_text ?? '';
$this->editCollection = $media->collection ?? '';
$this->showDetail = true;
};
$saveEdit = function () {
$media = DisplayMedia::find($this->editingId);
if (! $media) {
return;
}
$media->update([
'title' => $this->editTitle ?: null,
'alt_text' => $this->editAltText ?: null,
'collection' => $this->editCollection ?: null,
]);
Flux::toast(variant: 'success', heading: 'Gespeichert', text: 'Medien-Details wurden aktualisiert.');
};
$deleteMedia = function (int $id) {
$media = DisplayMedia::find($id);
if (! $media) {
return;
}
$filename = $media->getDisplayName();
$service = app(DisplayMediaService::class);
$service->delete($media);
$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">
<div>
<flux:heading size="xl">Display-Mediathek</flux:heading>
<flux:text class="mt-1">Bilder, Videos und externe URLs für Store Displays verwalten.</flux:text>
</div>
<div class="flex items-center gap-3">
<flux:badge color="sky">{{ $this->stats['images'] }} Bilder</flux:badge>
<flux:badge color="purple">{{ $this->stats['videos'] }} Videos</flux:badge>
<flux:badge color="blue">{{ $this->stats['externals'] }} Extern</flux:badge>
</div>
</div>
{{-- Upload + External URL --}}
<flux:card class="mb-6">
<div class="flex items-start gap-4">
<div class="flex-1">
<flux:file-upload wire:model="uploads" multiple
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,.jpg,.jpeg,.png,.webp,.mp4,.webm,.mov">
<flux:file-upload.dropzone
heading="Dateien hochladen"
text="Bilder & Videos bis 50 MB Drag & Drop oder klicken"
with-progress />
</flux:file-upload>
@if (isset($uploads) && count($uploads) > 0)
<div class="mt-3 flex flex-wrap items-center gap-2">
@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()" />
@endforeach
</div>
<flux:button wire:click="handleUploads" variant="primary" size="sm" class="mt-3">
{{ count($uploads) }} Datei(en) hochladen
</flux:button>
@endif
<flux:error name="uploads" />
</div>
<div class="flex flex-col gap-2 pt-2">
<flux:button wire:click="openUrlModal" icon="link" variant="ghost">
Externe URL anlegen
</flux:button>
</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="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="video">Videos</flux:select.option>
</flux:select>
<flux:select wire:model.live="filterSource" size="sm" class="w-36">
<flux:select.option value="all">Alle Quellen</flux:select.option>
<flux:select.option value="upload">Uploads</flux:select.option>
<flux:select.option value="external">Extern</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 Sammlungen</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>
{{-- Media Grid / List + Detail --}}
<div class="grid grid-cols-1 gap-6 {{ $showDetail ? 'lg:grid-cols-3' : '' }}">
<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="dm-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() && $item->isUpload())
<img src="{{ $item->getThumbnailUrl() }}"
alt="{{ $item->alt_text ?? $item->filename }}"
class="h-full w-full object-cover" loading="lazy" />
@elseif ($item->isVideo())
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-purple-400">
<x-heroicon-o-film class="h-10 w-10" />
<span class="text-xs">Video</span>
</div>
@elseif ($item->isExternal() && $item->isImage())
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-blue-400">
<x-heroicon-o-photo class="h-10 w-10" />
<span class="text-xs">Extern</span>
</div>
@else
<div class="flex h-full w-full flex-col items-center justify-center gap-2 text-zinc-400">
<x-heroicon-o-link class="h-10 w-10" />
<span class="text-xs">Link</span>
</div>
@endif
</div>
<div class="flex items-center gap-1.5 p-2">
@if ($item->isVideo())
<x-heroicon-s-film class="h-3.5 w-3.5 shrink-0 text-purple-500" />
@elseif ($item->isExternal())
<x-heroicon-s-link class="h-3.5 w-3.5 shrink-0 text-blue-500" />
@else
<x-heroicon-s-photo class="h-3.5 w-3.5 shrink-0 text-sky-500" />
@endif
<div class="min-w-0">
<p class="truncate text-xs font-medium text-zinc-700 dark:text-zinc-300">{{ $item->getDisplayName() }}</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 vorhanden. Laden Sie Dateien hoch oder legen Sie externe URLs an.</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">Name</th>
<th class="hidden px-3 py-2 sm:table-cell">Typ</th>
<th class="hidden px-3 py-2 sm:table-cell">Quelle</th>
<th class="hidden px-3 py-2 md:table-cell">Größe</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="dm-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="flex h-8 w-8 items-center justify-center overflow-hidden rounded border border-zinc-200 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800">
@if ($item->isImage() && $item->isUpload())
<img src="{{ $item->getThumbnailUrl() }}" class="h-full w-full object-cover" loading="lazy" />
@elseif ($item->isVideo())
<x-heroicon-s-film class="h-4 w-4 text-purple-500" />
@else
<x-heroicon-s-link class="h-4 w-4 text-blue-500" />
@endif
</div>
</td>
<td class="px-3 py-1.5">
<span class="font-medium text-zinc-700 dark:text-zinc-300">{{ $item->getDisplayName() }}</span>
</td>
<td class="hidden px-3 py-1.5 sm:table-cell">
<flux:badge size="sm" :color="$item->isVideo() ? 'purple' : 'sky'">
{{ $item->isVideo() ? 'Video' : 'Bild' }}
</flux:badge>
</td>
<td class="hidden px-3 py-1.5 sm:table-cell">
<flux:badge size="sm" :color="$item->isExternal() ? 'blue' : 'zinc'">
{{ $item->isExternal() ? 'Extern' : 'Upload' }}
</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 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 vorhanden.</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 = \App\Models\DisplayMedia::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>
{{-- 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() && $editMedia->isUpload())
<img src="{{ $editMedia->getUrl() }}" alt="{{ $editMedia->filename }}"
class="w-full object-contain" style="max-height: 300px;" />
@elseif ($editMedia->isVideo() && $editMedia->isUpload())
<video controls class="w-full" style="max-height: 300px;">
<source src="{{ $editMedia->getUrl() }}" type="{{ $editMedia->mime_type }}">
</video>
@elseif ($editMedia->isExternal())
<div class="flex flex-col items-center justify-center gap-3 py-8">
<x-heroicon-o-link class="h-12 w-12 text-blue-400" />
<span class="text-sm text-zinc-500">Externe Ressource</span>
</div>
@endif
</div>
{{-- Metadata --}}
<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>
<flux:badge size="sm" :color="$editMedia->isVideo() ? 'purple' : 'sky'" class="inline">
{{ $editMedia->isVideo() ? 'Video' : 'Bild' }}
</flux:badge>
</p>
<p><strong>Quelle:</strong>
<flux:badge size="sm" :color="$editMedia->isExternal() ? 'blue' : 'zinc'" class="inline">
{{ $editMedia->isExternal() ? 'Extern' : 'Upload' }}
</flux:badge>
</p>
@if ($editMedia->isUpload())
<p><strong>Größe:</strong> {{ $editMedia->getHumanFileSize() }}</p>
@if ($editMedia->mime_type)
<p><strong>MIME:</strong> {{ $editMedia->mime_type }}</p>
@endif
@if ($editMedia->metadata && isset($editMedia->metadata['width']))
<p><strong>Abmessungen:</strong> {{ $editMedia->metadata['width'] }}×{{ $editMedia->metadata['height'] }} px</p>
@endif
@endif
<p><strong>Angelegt:</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">
{{ Str::limit($editMedia->getUrl(), 80) }}
</a>
</p>
</div>
{{-- Edit Form --}}
<div class="space-y-3">
<flux:input wire:model="editTitle" label="Titel" size="sm" placeholder="Anzeigename..." />
<flux:input wire:model="editAltText" label="Alt-Text" size="sm" placeholder="Bildbeschreibung..." />
<flux:input wire:model="editCollection" label="Sammlung" size="sm" placeholder="z.B. immobilien, moebel, brand..." />
<flux:button size="sm" variant="primary" wire:click="saveEdit" class="w-full">
Speichern
</flux:button>
</div>
<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="Dieses Medium wirklich löschen?">
Löschen
</flux:button>
</div>
</flux:card>
</div>
@endif
@endif
</div>
{{-- External URL Modal --}}
<flux:modal wire:model="showUrlModal" class="max-w-lg">
<form wire:submit.prevent="saveExternalUrl">
<div class="space-y-6">
<div>
<flux:heading size="lg">Externe URL anlegen</flux:heading>
<flux:text class="mt-1">Binden Sie große Videos oder Medien von Google Drive, OneDrive oder anderen Quellen ein.</flux:text>
</div>
<div class="space-y-4">
<div>
<flux:input wire:model="urlInput" label="URL" placeholder="https://drive.google.com/file/d/..." />
<flux:error name="urlInput" />
</div>
<div class="flex items-end gap-3">
<flux:button type="button" wire:click="validateUrl" size="sm" variant="ghost" icon="arrow-path">
URL prüfen
</flux:button>
@if ($urlValidated === true)
<span class="flex items-center gap-1 text-sm text-green-600">
<x-heroicon-s-check-circle class="h-4 w-4" /> Erreichbar
</span>
@elseif ($urlValidated === false)
<span class="flex items-center gap-1 text-sm text-amber-600">
<x-heroicon-s-exclamation-triangle class="h-4 w-4" /> Nicht erreichbar trotzdem speichern möglich
</span>
@endif
</div>
<flux:select wire:model="urlType" label="Medientyp">
<option value="video">Video</option>
<option value="image">Bild</option>
</flux:select>
<flux:input wire:model="urlTitle" label="Titel (optional)" placeholder="z.B. Showroom-Rundgang 4K" />
<flux:input wire:model="urlCollection" label="Sammlung (optional)" placeholder="z.B. immobilien, moebel..." />
</div>
<div class="flex justify-end gap-3 pt-4">
<flux:button type="button" wire:click="$set('showUrlModal', false)" variant="ghost">
Abbrechen
</flux:button>
<flux:button type="submit" variant="primary">
Anlegen
</flux:button>
</div>
</div>
</form>
</flux:modal>
</div>

View file

@ -0,0 +1,136 @@
<div>
<div class="flex items-end gap-3">
<div class="flex-1">
@if ($selectedMedia)
<div class="flex items-center gap-3 rounded-lg border border-zinc-200 p-2 dark:border-zinc-700">
@if ($selectedMedia->isImage() && $selectedMedia->isUpload())
<img src="{{ $selectedMedia->getThumbnailUrl() }}"
alt="{{ $selectedMedia->filename }}"
class="h-16 w-16 rounded-md object-cover" />
@elseif ($selectedMedia->isVideo())
<div class="flex h-16 w-16 items-center justify-center rounded-md bg-purple-50 dark:bg-purple-900/20">
<x-heroicon-o-film class="h-8 w-8 text-purple-500" />
</div>
@elseif ($selectedMedia->isExternal())
<div class="flex h-16 w-16 items-center justify-center rounded-md bg-blue-50 dark:bg-blue-900/20">
<x-heroicon-o-link class="h-8 w-8 text-blue-500" />
</div>
@else
<div class="flex h-16 w-16 items-center justify-center rounded-md bg-zinc-100 dark:bg-zinc-800">
<x-heroicon-o-photo class="h-8 w-8 text-zinc-400" />
</div>
@endif
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-zinc-700 dark:text-zinc-300">
{{ $selectedMedia->getDisplayName() }}
</p>
<div class="flex items-center gap-2 text-xs text-zinc-400">
<span>{{ $selectedMedia->getHumanFileSize() }}</span>
@if ($selectedMedia->isExternal())
<flux:badge size="sm" color="blue">Extern</flux:badge>
@endif
<flux:badge size="sm" :color="$selectedMedia->isVideo() ? 'purple' : 'sky'">
{{ $selectedMedia->isVideo() ? 'Video' : 'Bild' }}
</flux:badge>
</div>
</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>
<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>
<div class="flex flex-col gap-3">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Suchen (Dateiname oder Titel)..." icon="magnifying-glass" size="sm" />
<flux:file-upload wire:model="quickUploads" multiple
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm,.jpg,.jpeg,.png,.webp,.mp4,.webm,.mov">
<flux:file-upload.dropzone
heading="Datei hochladen"
text="Bilder bis 50 MB, Videos bis 50 MB größere Videos bitte über die Mediathek als externe URL anlegen"
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>
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
@forelse ($mediaItems as $item)
<div wire:key="dpick-{{ $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="relative aspect-square bg-zinc-100 dark:bg-zinc-800">
@if ($item->isImage() && $item->isUpload())
<img src="{{ $item->getThumbnailUrl() }}"
alt="{{ $item->filename }}" class="h-full w-full object-cover" loading="lazy" />
@elseif ($item->isVideo())
<div class="flex h-full w-full items-center justify-center text-purple-500">
<x-heroicon-o-film class="h-10 w-10" />
</div>
@elseif ($item->isExternal() && $item->isImage())
<div class="flex h-full w-full items-center justify-center text-blue-500">
<x-heroicon-o-photo class="h-10 w-10" />
</div>
@else
<div class="flex h-full w-full items-center justify-center text-zinc-400">
<x-heroicon-o-link class="h-10 w-10" />
</div>
@endif
{{-- Badges --}}
<div class="absolute right-1 top-1 flex flex-col gap-1">
@if ($item->isExternal())
<flux:badge size="sm" color="blue" class="text-[10px]!">URL</flux:badge>
@endif
@if ($item->isVideo())
<flux:badge size="sm" color="purple" class="text-[10px]!">Video</flux:badge>
@endif
</div>
</div>
<div class="p-1.5">
<p class="truncate text-[11px] font-medium text-zinc-600 dark:text-zinc-400">{{ $item->getDisplayName() }}</p>
<p class="truncate text-[10px] text-zinc-400">{{ $item->getHumanFileSize() }}</p>
</div>
</div>
@empty
<div class="col-span-full py-8 text-center">
<flux:text>Keine Medien gefunden. Laden Sie Dateien hoch oder legen Sie externe URLs in der Mediathek an.</flux:text>
</div>
@endforelse
</div>
@if ($mediaItems->hasPages())
<div class="mt-2">
{{ $mediaItems->links() }}
</div>
@endif
</flux:modal>
</div>

View file

@ -0,0 +1,255 @@
<div>
{{-- Header --}}
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-4">
<flux:button :href="route('admin.cms.display-versions')" wire:navigate variant="ghost" icon="arrow-left" size="sm">
{{ __('Zurück') }}
</flux:button>
<div>
<div class="flex items-center gap-3">
<flux:heading size="xl">{{ $version->name }}</flux:heading>
<flux:badge color="{{ match($version->type->value) {
'video-display' => 'purple',
'b2in' => 'blue',
'offers' => 'amber',
} }}">
{{ $version->type->label() }}
</flux:badge>
</div>
<flux:subheading>{{ __('Version bearbeiten') }}</flux:subheading>
</div>
</div>
<div class="flex items-center gap-3">
@if($version->type->value === 'b2in')
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700">
<flux:icon.sun class="w-4 h-4 text-amber-500" />
<flux:switch wire:click="toggleTheme"
:checked="($version->settings['theme'] ?? 'dark') === 'dark'" />
<flux:icon.moon class="w-4 h-4 text-indigo-400" />
</div>
@endif
<flux:button wire:click="openSettingsModal" icon="cog-6-tooth" variant="ghost">
{{ __('Einstellungen') }}
</flux:button>
</div>
</div>
@if (session()->has('success'))
<x-success-alert>
{{ session('success') }}
</x-success-alert>
@endif
{{-- Name bearbeiten --}}
<flux:card class="mb-6">
<form wire:submit.prevent="saveName" class="flex items-end gap-4">
<div class="flex-1">
<flux:input wire:model="versionName" label="Versionsname" />
</div>
<flux:button type="submit" variant="primary" size="sm">{{ __('Speichern') }}</flux:button>
</form>
</flux:card>
{{-- Type-specific content sections --}}
@if($version->type->value === 'video-display')
@include('livewire.admin.cms.partials.version-editor-video', ['items' => $items])
@elseif($version->type->value === 'b2in')
@include('livewire.admin.cms.partials.version-editor-b2in', ['items' => $items])
@elseif($version->type->value === 'offers')
@include('livewire.admin.cms.partials.version-editor-offers', ['items' => $items])
@endif
{{-- Item Modal --}}
<flux:modal :open="$showItemModal" wire:model="showItemModal">
<form wire:submit.prevent="saveItem">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ $itemId ? __('Inhalt bearbeiten') : __('Inhalt hinzufügen') }}</flux:heading>
</div>
{{-- Video fields --}}
@if($itemType === 'video')
<livewire:admin.cms.display-media-picker
:value="null"
field="videoFilename"
type="video"
label="Video aus Mediathek"
:key="'picker-video-' . ($itemId ?? 'new')" />
<flux:input wire:model="videoFilename" label="Video-Pfad / URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
description="Über die Mediathek auswählen oder direkt Pfad/URL eingeben." />
<flux:input wire:model="videoTitle" label="Titel (optional)" placeholder="z.B. Herbst Kollektion 2025" />
<flux:input wire:model="videoPosition" type="number" min="0" max="100" label="Position (%)"
description="Vertikale Position im Video (0 = oben, 100 = unten)" />
<flux:checkbox wire:model="videoIsActive" label="Aktiv" />
@endif
{{-- Footer fields --}}
@if($itemType === 'footer')
<flux:input wire:model="footerHeadline" label="Überschrift" placeholder="z.B. Beratung & Termin" />
<flux:input wire:model="footerSubline" label="Unterzeile" placeholder="z.B. Jetzt Termin vereinbaren." />
<flux:input wire:model="footerUrl" label="URL (optional)" placeholder="https://..."
description="Leer = kein QR-Code. Mit URL = QR-Code wird generiert." />
<flux:checkbox wire:model="footerIsActive" label="Aktiv" />
@endif
{{-- Media fields (B2in) --}}
@if($itemType === 'media')
<flux:select wire:model="mediaType" label="Medientyp">
<option value="image">Bild</option>
<option value="video">Video</option>
</flux:select>
<flux:select wire:model="mediaCategory" label="Kategorie">
<option value="immobilien">Immobilien</option>
<option value="moebel">Möbel</option>
</flux:select>
<livewire:admin.cms.display-media-picker
:value="null"
field="mediaUrl"
:type="$mediaType === 'video' ? 'video' : 'image'"
label="Aus Mediathek"
:key="'picker-media-' . ($itemId ?? 'new')" />
<flux:input wire:model="mediaUrl" label="Medien-URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
description="Über die Mediathek auswählen oder direkt URL eingeben." />
<flux:input wire:model="mediaHeadline" label="Überschrift" placeholder="z.B. Ihr Zuhause. Weltweit." />
<flux:input wire:model="mediaSubline" label="Unterzeile" placeholder="z.B. Beratung und Vermittlung." />
@if($mediaType === 'image')
<flux:input wire:model="mediaDuration" type="number" min="1" max="120" label="Dauer (Sekunden)"
description="Nur für Bilder Videos spielen bis zum Ende." />
@endif
<flux:checkbox wire:model="mediaIsActive" label="Aktiv" />
@endif
{{-- Slide fields (Offers) --}}
@if($itemType === 'slide')
{{-- Basis --}}
<flux:select wire:model.live="slideType" label="Slide-Typ">
<option value="intro">Intro</option>
<option value="product-hero">Produkt-Hero</option>
<option value="product-details">Produkt-Details</option>
<option value="product-impulse">Produkt-Impuls</option>
</flux:select>
<flux:input wire:model="slideDuration" type="number" min="1000" label="Dauer (ms)" />
<livewire:admin.cms.display-media-picker
:value="null"
field="slideImageUrl"
type="image"
label="Bild aus Mediathek"
:key="'picker-slide-' . ($itemId ?? 'new')" />
<flux:input wire:model="slideImageUrl" label="Bild-URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
description="Über die Mediathek auswählen oder direkt URL eingeben." />
<flux:input wire:model="slideBadge" label="Badge-Text" placeholder="z.B. Einzelstück" />
<flux:input wire:model="slideEyebrow" label="Eyebrow" placeholder="z.B. Hersteller: Sudbrock" />
<flux:input wire:model="slideTitle" label="Titel" placeholder="z.B. GOYA Sideboard" />
{{-- Intro-spezifisch --}}
@if($slideType === 'intro')
<flux:input wire:model="slideDisclaimer" label="Disclaimer" placeholder="z.B. Zwischenverkauf vorbehalten" />
<flux:checkbox wire:model="slideShowBrandText" label="Brand-Text anzeigen" />
@if($slideShowBrandText)
<flux:input wire:model="slideBrandTagline" label="Brand-Tagline" placeholder="z.B. Planung • Beratung • Lieferung & Montage" />
@endif
@endif
{{-- Product-Hero --}}
@if($slideType === 'product-hero')
<flux:input wire:model="slidePrice" label="Preis" placeholder="z.B. 489 €" />
<flux:input wire:model="slideOriginalPrice" label="Originalpreis" placeholder="z.B. statt 4.744 €" />
@endif
{{-- Product-Details --}}
@if($slideType === 'product-details')
<div>
<flux:heading size="sm" class="mb-2">{{ __('Aufzählungspunkte') }}</flux:heading>
<div class="space-y-2">
@foreach($slideBullets as $i => $bullet)
<div class="flex items-center gap-2" wire:key="bullet-{{ $i }}">
<flux:input wire:model="slideBullets.{{ $i }}" placeholder="Punkt {{ $i + 1 }}" class="flex-1" />
<flux:button wire:click="removeBullet({{ $i }})" size="xs" variant="ghost" icon="x-mark" class="text-red-500"></flux:button>
</div>
@endforeach
</div>
<flux:button wire:click="addBullet" size="xs" variant="ghost" icon="plus" class="mt-2">
{{ __('Punkt hinzufügen') }}
</flux:button>
</div>
@endif
{{-- Product-Impulse --}}
@if($slideType === 'product-impulse')
<flux:input wire:model="slideSubline" label="Subline" placeholder="z.B. Heute mitnehmen" />
<flux:input wire:model="slidePrice" label="Preis" placeholder="z.B. 199 €" />
<flux:input wire:model="slideTagText" label="Tag-Text" placeholder="z.B. Im Store verfügbar" />
@endif
{{-- QR --}}
<div class="border-t border-zinc-200 dark:border-zinc-700 pt-4 mt-2">
<flux:heading size="sm" class="mb-3">{{ __('QR-Code & Kontakt') }}</flux:heading>
<div class="space-y-4">
<flux:input wire:model="slideQrUrl" label="QR-URL" placeholder="z.B. https://cabinet-bielefeld.de" />
<flux:input wire:model="slideQrTitle" label="QR-Titel" placeholder="z.B. Reservieren" />
<flux:input wire:model="slideContact" label="Kontakt" placeholder="z.B. 0521 98620100 / Tel. oder WhatsApp" />
</div>
</div>
<flux:checkbox wire:model="slideIsActive" label="Aktiv" />
@endif
<div class="flex justify-end gap-3 pt-4">
<flux:button type="button" wire:click="closeItemModal" variant="ghost">
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ $itemId ? __('Aktualisieren') : __('Hinzufügen') }}
</flux:button>
</div>
</div>
</form>
</flux:modal>
{{-- Settings Modal --}}
<flux:modal :open="$showSettingsModal" wire:model="showSettingsModal">
<form wire:submit.prevent="saveSettings">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Einstellungen') }}</flux:heading>
<flux:subheading>{{ $version->type->label() }}</flux:subheading>
</div>
@if($version->type->value === 'b2in')
<flux:select wire:model="settings.theme" label="Theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</flux:select>
<flux:input wire:model="settings.footer_name" label="Footer Name" placeholder="z.B. Marcel Scheibe" />
<flux:input wire:model="settings.footer_url" label="Footer URL" placeholder="z.B. b2in.de" />
<flux:select wire:model="settings.transition.type" label="Transition">
<option value="crossfade">Crossfade</option>
<option value="fade">Fade</option>
<option value="slide">Slide</option>
</flux:select>
<flux:input wire:model="settings.transition.duration_ms" type="number" label="Transition-Dauer (ms)" />
<flux:input wire:model="settings.default_image_duration" type="number" label="Standard-Bilddauer (Sek.)" />
<flux:checkbox wire:model="settings.display_active" label="Display aktiv" />
@elseif($version->type->value === 'offers')
<flux:checkbox wire:model="settings.loop" label="Endlosschleife" />
<flux:select wire:model="settings.transition.type" label="Transition">
<option value="fade">Fade</option>
<option value="slide">Slide</option>
</flux:select>
<flux:input wire:model="settings.transition.duration" type="number" label="Transition-Dauer (ms)" />
@elseif($version->type->value === 'video-display')
<p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('Keine speziellen Einstellungen für diesen Typ.') }}</p>
@endif
<div class="flex justify-end gap-3 pt-4">
<flux:button type="button" wire:click="$set('showSettingsModal', false)" variant="ghost">
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ __('Speichern') }}
</flux:button>
</div>
</div>
</form>
</flux:modal>
</div>

View file

@ -0,0 +1,116 @@
<div>
<flux:header class="mb-6">
<flux:heading size="xl">{{ __('Display-Versionen') }}</flux:heading>
<flux:subheading>{{ __('Erstellen und verwalten Sie Inhalts-Versionen für Ihre Displays') }}</flux:subheading>
</flux:header>
@if (session()->has('success'))
<x-success-alert>
{{ session('success') }}
</x-success-alert>
@endif
<flux:card>
<div class="flex items-center justify-between mb-6">
<div>
<flux:heading size="lg">{{ __('Versionen') }}</flux:heading>
<flux:subheading>{{ __('Jede Version enthält Inhalte eines bestimmten Typs') }}</flux:subheading>
</div>
<flux:button wire:click="openCreateModal" icon="plus" variant="primary">
{{ __('Version erstellen') }}
</flux:button>
</div>
@if($versions->isEmpty())
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
<flux:icon.rectangle-group class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>{{ __('Noch keine Versionen vorhanden. Erstellen Sie Ihre erste Version!') }}</p>
</div>
@else
<div class="space-y-3">
@foreach($versions as $version)
<div wire:key="version-{{ $version->id }}"
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600 transition">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1">
<flux:badge :color="$version->is_active ? 'green' : 'zinc'" size="sm">
{{ $version->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
<a href="{{ route('admin.cms.display-version-edit', $version) }}"
wire:navigate
class="font-semibold text-sm hover:underline">
{{ $version->name }}
</a>
<flux:badge color="{{ match($version->type->value) {
'video-display' => 'purple',
'b2in' => 'blue',
'offers' => 'amber',
} }}" size="sm">
{{ $version->type->label() }}
</flux:badge>
</div>
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4">
<span>{{ $version->items_count }} {{ __('Inhalte') }}</span>
<span>{{ $version->displays_count }} {{ __('Displays') }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<flux:button wire:click="toggleActive({{ $version->id }})"
size="sm"
variant="ghost"
:icon="$version->is_active ? 'eye-slash' : 'eye'">
</flux:button>
<flux:button :href="route('admin.cms.display-version-edit', $version)"
wire:navigate
size="sm"
variant="ghost"
icon="pencil">
</flux:button>
<flux:button wire:click="deleteVersion({{ $version->id }})"
wire:confirm="Möchten Sie diese Version wirklich löschen? Alle zugehörigen Inhalte werden ebenfalls gelöscht."
size="sm"
variant="ghost"
icon="trash"
class="text-red-600 hover:text-red-700">
</flux:button>
</div>
</div>
@endforeach
</div>
@endif
</flux:card>
{{-- Create Modal --}}
<flux:modal :open="$showCreateModal" wire:model="showCreateModal">
<form wire:submit.prevent="createVersion">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Neue Version erstellen') }}</flux:heading>
</div>
<flux:input wire:model="newName" label="Name" placeholder="z.B. Herbst 2025 Video" />
@error('newName') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<flux:select wire:model="newType" label="Typ" placeholder="Typ auswählen...">
@foreach($types as $type)
<option value="{{ $type->value }}">{{ $type->label() }}</option>
@endforeach
</flux:select>
@error('newType') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
<div class="flex justify-end gap-3 pt-4">
<flux:button type="button" wire:click="$set('showCreateModal', false)" variant="ghost">
{{ __('Abbrechen') }}
</flux:button>
<flux:button type="submit" variant="primary">
{{ __('Erstellen & Bearbeiten') }}
</flux:button>
</div>
</div>
</form>
</flux:modal>
</div>

View file

@ -0,0 +1,441 @@
<?php
use Flux\Flux;
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\MediaConversionService;
use Illuminate\Support\Facades\Storage;
use function Livewire\Volt\{layout, title, state, computed, on};
layout('components.layouts.app');
title('Medienbibliothek');
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>
<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>
<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' : '' }}">
<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
<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>
@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>
<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>
<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>
<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>
<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>
@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
<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,124 @@
<div>
<div class="flex items-end gap-3">
<div class="flex-1">
@if ($selectedMedia)
<div class="flex items-center gap-3 rounded-lg border border-zinc-200 p-2 dark:border-zinc-700">
@if ($selectedMedia->isImage())
<img src="{{ $selectedMedia->hasConversion('thumb') ? $selectedMedia->getConversionUrl('thumb') : $selectedMedia->getUrl() }}"
alt="{{ $selectedMedia->filename }}"
class="h-16 w-16 rounded-md object-cover" />
@elseif ($selectedMedia->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">
{{ $selectedMedia->filename }}
</p>
<p class="text-xs text-zinc-400">
{{ $selectedMedia->getHumanFileSize() }}
@if ($selectedMedia->getDimensionsLabel())
{{ $selectedMedia->getDimensionsLabel() }}
@endif
</p>
@if ($selectedMedia->isImage() && $selectedMedia->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>
<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>
<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
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
@forelse ($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 ($mediaItems->hasPages())
<div class="mt-2">
{{ $mediaItems->links() }}
</div>
@endif
</flux:modal>
</div>

View file

@ -0,0 +1,67 @@
{{-- B2in: Media-Playlist --}}
@php
$mediaItems = $items->get('media', collect());
@endphp
<flux:card>
<div class="flex items-center justify-between mb-6">
<div>
<flux:heading size="lg">{{ __('Media-Playlist') }}</flux:heading>
<flux:subheading>{{ __('Bilder und Videos werden rotierend angezeigt') }}</flux:subheading>
</div>
<flux:button wire:click="openItemModal(null, 'media')" icon="plus">
{{ __('Medium hinzufügen') }}
</flux:button>
</div>
@if($mediaItems->isEmpty())
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
<flux:icon.photo class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>{{ __('Noch keine Medien vorhanden.') }}</p>
</div>
@else
<div class="space-y-3">
@foreach($mediaItems as $index => $item)
<div wire:key="item-{{ $item->id }}"
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
<div class="flex flex-col gap-1">
@if($index > 0)
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button>
@endif
@if($index < count($mediaItems) - 1)
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs" variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600"></flux:button>
@endif
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1">
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
<flux:badge color="{{ ($item->content['media_type'] ?? 'image') === 'video' ? 'purple' : 'sky' }}" size="sm">
{{ ($item->content['media_type'] ?? 'image') === 'video' ? 'Video' : 'Bild' }}
</flux:badge>
<flux:badge color="zinc" size="sm">
{{ ucfirst($item->content['category'] ?? '') }}
</flux:badge>
<span class="font-semibold text-sm truncate">{{ $item->content['headline'] ?? '' }}</span>
</div>
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4">
<span>{{ $item->content['subline'] ?? '' }}</span>
@if(($item->content['media_type'] ?? 'image') === 'image')
<span>{{ $item->content['duration_seconds'] ?? 10 }}s</span>
@endif
</div>
</div>
<div class="flex items-center gap-2">
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost" :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost" icon="pencil"></flux:button>
<flux:button wire:click="deleteItem({{ $item->id }})" wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost" icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
</div>
</div>
@endforeach
</div>
@endif
</flux:card>

View file

@ -0,0 +1,73 @@
{{-- Offers: Slides --}}
@php
$slides = $items->get('slide', collect());
@endphp
<flux:card>
<div class="flex items-center justify-between mb-6">
<div>
<flux:heading size="lg">{{ __('Slides') }}</flux:heading>
<flux:subheading>{{ __('Angebots-Slides werden in der angegebenen Reihenfolge angezeigt') }}</flux:subheading>
</div>
<flux:button wire:click="openItemModal(null, 'slide')" icon="plus">
{{ __('Slide hinzufügen') }}
</flux:button>
</div>
@if($slides->isEmpty())
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
<flux:icon.presentation-chart-bar class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>{{ __('Noch keine Slides vorhanden.') }}</p>
</div>
@else
<div class="space-y-3">
@foreach($slides as $index => $item)
<div wire:key="item-{{ $item->id }}"
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
<div class="flex flex-col gap-1">
@if($index > 0)
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button>
@endif
@if($index < count($slides) - 1)
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs" variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600"></flux:button>
@endif
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1">
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
<flux:badge color="amber" size="sm">
{{ match($item->content['type'] ?? '') {
'intro' => 'Intro',
'product-hero' => 'Produkt-Hero',
'product-details' => 'Produkt-Details',
'product-impulse' => 'Produkt-Impuls',
default => $item->content['type'] ?? '',
} }}
</flux:badge>
<span class="font-semibold text-sm">{{ $item->content['title'] ?? '' }}</span>
</div>
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4">
@if(!empty($item->content['price']))
<span class="font-medium">{{ $item->content['price'] }}</span>
@endif
<span>{{ number_format(($item->content['duration'] ?? 8000) / 1000, 1) }}s</span>
@if(!empty($item->content['badge_text']))
<span>{{ $item->content['badge_text'] }}</span>
@endif
</div>
</div>
<div class="flex items-center gap-2">
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost" :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost" icon="pencil"></flux:button>
<flux:button wire:click="deleteItem({{ $item->id }})" wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost" icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
</div>
</div>
@endforeach
</div>
@endif
</flux:card>

View file

@ -0,0 +1,119 @@
{{-- Video-Display: Video-Playlist + Footer-Inhalte --}}
@php
$videos = $items->get('video', collect());
$footers = $items->get('footer', collect());
@endphp
{{-- Video-Playlist --}}
<flux:card class="mb-8">
<div class="flex items-center justify-between mb-6">
<div>
<flux:heading size="lg">{{ __('Video-Playlist') }}</flux:heading>
<flux:subheading>{{ __('Videos werden in der angegebenen Reihenfolge abgespielt') }}</flux:subheading>
</div>
<flux:button wire:click="openItemModal(null, 'video')" icon="plus">
{{ __('Video hinzufügen') }}
</flux:button>
</div>
@if($videos->isEmpty())
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
<flux:icon.film class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>{{ __('Noch keine Videos vorhanden.') }}</p>
</div>
@else
<div class="space-y-3">
@foreach($videos as $index => $item)
<div wire:key="item-{{ $item->id }}"
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
<div class="flex flex-col gap-1">
@if($index > 0)
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button>
@endif
@if($index < count($videos) - 1)
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs" variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600"></flux:button>
@endif
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1">
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
<span class="font-semibold text-sm">{{ $item->content['title'] ?? $item->content['filename'] ?? '' }}</span>
</div>
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4">
<span>{{ $item->content['filename'] ?? '' }}</span>
<span>Position: {{ $item->content['position'] ?? 25 }}%</span>
</div>
</div>
<div class="flex items-center gap-2">
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost" :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost" icon="pencil"></flux:button>
<flux:button wire:click="deleteItem({{ $item->id }})" wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost" icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
</div>
</div>
@endforeach
</div>
@endif
</flux:card>
{{-- Footer-Inhalte --}}
<flux:card>
<div class="flex items-center justify-between mb-6">
<div>
<flux:heading size="lg">{{ __('Footer-Inhalte') }}</flux:heading>
<flux:subheading>{{ __('Inhalte werden im Footer rotiert') }}</flux:subheading>
</div>
<flux:button wire:click="openItemModal(null, 'footer')" icon="plus">
{{ __('Inhalt hinzufügen') }}
</flux:button>
</div>
@if($footers->isEmpty())
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
<flux:icon.document-text class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>{{ __('Noch keine Footer-Inhalte vorhanden.') }}</p>
</div>
@else
<div class="space-y-3">
@foreach($footers as $index => $item)
<div wire:key="item-{{ $item->id }}"
class="flex items-center gap-4 p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 transition">
<div class="flex flex-col gap-1">
@if($index > 0)
<flux:button wire:click="moveItem({{ $item->id }}, 'up')" size="xs" variant="ghost" icon="chevron-up" class="text-zinc-400 hover:text-zinc-600"></flux:button>
@endif
@if($index < count($footers) - 1)
<flux:button wire:click="moveItem({{ $item->id }}, 'down')" size="xs" variant="ghost" icon="chevron-down" class="text-zinc-400 hover:text-zinc-600"></flux:button>
@endif
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-1">
<flux:badge :color="$item->is_active ? 'green' : 'zinc'" size="sm">
{{ $item->is_active ? __('Aktiv') : __('Inaktiv') }}
</flux:badge>
<span class="font-semibold text-sm">{{ $item->content['headline'] ?? '' }}</span>
</div>
<div class="text-xs text-zinc-600 dark:text-zinc-400">
{{ $item->content['subline'] ?? '' }}
@if(!empty($item->content['url']))
<span class="ml-2">{{ Str::limit($item->content['url'], 40) }}</span>
@endif
</div>
</div>
<div class="flex items-center gap-2">
<flux:button wire:click="toggleItemStatus({{ $item->id }})" size="sm" variant="ghost" :icon="$item->is_active ? 'eye-slash' : 'eye'"></flux:button>
<flux:button wire:click="openItemModal({{ $item->id }})" size="sm" variant="ghost" icon="pencil"></flux:button>
<flux:button wire:click="deleteItem({{ $item->id }})" wire:confirm="Möchten Sie diesen Eintrag wirklich löschen?" size="sm" variant="ghost" icon="trash" class="text-red-600 hover:text-red-700"></flux:button>
</div>
</div>
@endforeach
</div>
@endif
</flux:card>

View file

@ -0,0 +1,720 @@
<?php
use App\Models\CmsProject;
use Flux\Flux;
use Illuminate\Support\Str;
use function Livewire\Volt\{layout, title, state, computed, on};
layout('components.layouts.app');
title('Projekte verwalten');
state([
'search' => '',
'showForm' => false,
'editingId' => null,
'editLocale' => 'de',
'activeTab' => 'basic',
'slug' => '',
'projectTitle' => '',
'location' => '',
'status' => '',
'launch_date' => '',
'price_from_aed' => '',
'currency' => 'AED',
'image' => '',
'is_published' => true,
'order' => 0,
'highlights' => [''],
'quick_facts' => [['icon' => 'home-modern', 'label' => '', 'value' => '']],
'investTitle' => '',
'investText' => '',
'investViews' => [''],
'galleryItems' => [''],
'locTitle' => '',
'locMapUrl' => '',
'locPoints' => [''],
'contactTitle' => '',
'contactSubtitle' => '',
'contactOptions' => [['key' => '', 'value' => '']],
'trustTitle' => '',
'trustIntro' => '',
'trustColumns' => [
['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''],
['icon' => 'heroicon-o-building-library', 'title' => '', 'text' => ''],
['icon' => 'heroicon-o-chart-bar', 'title' => '', 'text' => ''],
],
'trustCtaUrl' => '',
'trustCtaLabel' => '',
'furnitureTitle' => '',
'furnitureText' => '',
'furnitureButtonText' => '',
'furnitureButtonLink' => '',
]);
on(['media-selected' => function ($mediaId, $url, $field) {
$media = $mediaId ? \FluxCms\Core\Models\CmsMedia::find($mediaId) : null;
if (! $media) {
return;
}
if ($field === 'project_image') {
$this->image = $media->filename;
}
if (str_starts_with($field, 'gallery_')) {
$idx = (int) str_replace('gallery_', '', $field);
if (isset($this->galleryItems[$idx])) {
$this->galleryItems[$idx] = $media->filename;
}
}
}]);
$projects = computed(
fn () => CmsProject::query()
->when($this->search, fn ($q) => $q->where('slug', 'like', "%{$this->search}%"))
->ordered()
->get(),
);
$resetForm = function () {
$this->editingId = null;
$this->activeTab = 'basic';
$this->slug = '';
$this->projectTitle = '';
$this->location = '';
$this->status = '';
$this->launch_date = '';
$this->price_from_aed = '';
$this->currency = 'AED';
$this->image = '';
$this->is_published = true;
$this->order = 0;
$this->highlights = [''];
$this->quick_facts = [['icon' => 'home-modern', 'label' => '', 'value' => '']];
$this->investTitle = '';
$this->investText = '';
$this->investViews = [''];
$this->galleryItems = [''];
$this->locTitle = '';
$this->locMapUrl = '';
$this->locPoints = [''];
$this->contactTitle = '';
$this->contactSubtitle = '';
$this->contactOptions = [['key' => '', 'value' => '']];
$this->trustTitle = '';
$this->trustIntro = '';
$this->trustColumns = [
['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''],
['icon' => 'heroicon-o-building-library', 'title' => '', 'text' => ''],
['icon' => 'heroicon-o-chart-bar', 'title' => '', 'text' => ''],
];
$this->trustCtaUrl = '';
$this->trustCtaLabel = '';
$this->furnitureTitle = '';
$this->furnitureText = '';
$this->furnitureButtonText = '';
$this->furnitureButtonLink = '';
};
$loadProjectIntoForm = function (CmsProject $project, string $locale) {
$this->slug = $project->slug;
$this->image = $project->image ?? '';
$this->status = $project->status ?? '';
$this->launch_date = $project->launch_date?->format('Y-m-d') ?? '';
$this->price_from_aed = $project->price_from_aed ?? '';
$this->currency = $project->currency ?? 'AED';
$this->is_published = $project->is_published;
$this->order = $project->order ?? 0;
$this->projectTitle = $project->getTranslation('title', $locale) ?? '';
$this->location = $project->getTranslation('location', $locale) ?? '';
$hl = $project->getTranslation('highlights', $locale);
$this->highlights = is_array($hl) && count($hl) > 0 ? $hl : [''];
$this->quick_facts = is_array($project->quick_facts) && count($project->quick_facts) > 0
? $project->quick_facts
: [['icon' => 'home-modern', 'label' => '', 'value' => '']];
$ic = $project->getTranslation('investment_case', $locale);
$this->investTitle = $ic['title'] ?? '';
$this->investText = $ic['text'] ?? '';
$this->investViews = is_array($ic['views'] ?? null) && count($ic['views']) > 0 ? $ic['views'] : [''];
$this->galleryItems = is_array($project->gallery) && count($project->gallery) > 0
? $project->gallery
: [''];
$li = $project->getTranslation('location_info', $locale);
$this->locTitle = $li['title'] ?? '';
$this->locMapUrl = $li['map_url'] ?? '';
$this->locPoints = is_array($li['points'] ?? null) && count($li['points']) > 0 ? $li['points'] : [''];
$ct = $project->getTranslation('contact', $locale);
$this->contactTitle = $ct['title'] ?? '';
$this->contactSubtitle = $ct['subtitle'] ?? '';
$opts = $ct['options'] ?? [];
$this->contactOptions = count($opts) > 0
? collect($opts)->map(fn ($v, $k) => ['key' => $k, 'value' => $v])->values()->toArray()
: [['key' => '', 'value' => '']];
$it = $project->getTranslation('investor_trust', $locale) ?? [];
$this->trustTitle = $it['title'] ?? '';
$this->trustIntro = $it['intro'] ?? '';
$trustCols = $it['columns'] ?? [];
$this->trustColumns = is_array($trustCols) && count($trustCols) > 0
? $trustCols
: [
['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''],
['icon' => 'heroicon-o-building-library', 'title' => '', 'text' => ''],
['icon' => 'heroicon-o-chart-bar', 'title' => '', 'text' => ''],
];
$this->trustCtaUrl = $it['cta_url'] ?? '';
$this->trustCtaLabel = $it['cta_label'] ?? '';
$fb = $project->getTranslation('furniture_benefit', $locale) ?? [];
$this->furnitureTitle = $fb['title'] ?? '';
$this->furnitureText = $fb['text'] ?? '';
$this->furnitureButtonText = $fb['button_text'] ?? '';
$this->furnitureButtonLink = $fb['button_link'] ?? '';
};
$openCreate = function () {
$this->resetForm();
$this->showForm = true;
};
$openEdit = function (int $id) {
$project = CmsProject::find($id);
if (! $project) {
return;
}
$this->editingId = $id;
$this->activeTab = 'basic';
$this->loadProjectIntoForm($project, $this->editLocale);
$this->showForm = true;
};
$duplicateProject = function (int $id) {
$project = CmsProject::find($id);
if (! $project) {
return;
}
$this->editingId = null;
$this->activeTab = 'basic';
$this->loadProjectIntoForm($project, $this->editLocale);
$this->slug = $project->slug . '-kopie-' . Str::random(4);
$this->showForm = true;
Flux::toast(heading: 'Dupliziert', text: 'Projekt wurde als Kopie geladen. Bitte Slug anpassen und speichern.');
};
$switchLocale = function (string $locale) {
$this->editLocale = $locale;
if ($this->editingId) {
$project = CmsProject::find($this->editingId);
if ($project) {
$this->projectTitle = $project->getTranslation('title', $locale) ?? '';
$this->location = $project->getTranslation('location', $locale) ?? '';
$hl = $project->getTranslation('highlights', $locale);
$this->highlights = is_array($hl) && count($hl) > 0 ? $hl : [''];
$ic = $project->getTranslation('investment_case', $locale);
$this->investTitle = $ic['title'] ?? '';
$this->investText = $ic['text'] ?? '';
$this->investViews = is_array($ic['views'] ?? null) && count($ic['views']) > 0 ? $ic['views'] : [''];
$li = $project->getTranslation('location_info', $locale);
$this->locTitle = $li['title'] ?? '';
$this->locMapUrl = $li['map_url'] ?? '';
$this->locPoints = is_array($li['points'] ?? null) && count($li['points']) > 0 ? $li['points'] : [''];
$ct = $project->getTranslation('contact', $locale);
$this->contactTitle = $ct['title'] ?? '';
$this->contactSubtitle = $ct['subtitle'] ?? '';
$opts = $ct['options'] ?? [];
$this->contactOptions = count($opts) > 0
? collect($opts)->map(fn ($v, $k) => ['key' => $k, 'value' => $v])->values()->toArray()
: [['key' => '', 'value' => '']];
$it = $project->getTranslation('investor_trust', $locale) ?? [];
$this->trustTitle = $it['title'] ?? '';
$this->trustIntro = $it['intro'] ?? '';
$trustCols = $it['columns'] ?? [];
$this->trustColumns = is_array($trustCols) && count($trustCols) > 0
? $trustCols
: [
['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''],
['icon' => 'heroicon-o-building-library', 'title' => '', 'text' => ''],
['icon' => 'heroicon-o-chart-bar', 'title' => '', 'text' => ''],
];
$this->trustCtaUrl = $it['cta_url'] ?? '';
$this->trustCtaLabel = $it['cta_label'] ?? '';
$fb = $project->getTranslation('furniture_benefit', $locale) ?? [];
$this->furnitureTitle = $fb['title'] ?? '';
$this->furnitureText = $fb['text'] ?? '';
$this->furnitureButtonText = $fb['button_text'] ?? '';
$this->furnitureButtonLink = $fb['button_link'] ?? '';
}
}
};
$addHighlight = fn () => $this->highlights[] = '';
$removeHighlight = function (int $i) { unset($this->highlights[$i]); $this->highlights = array_values($this->highlights); };
$addQuickFact = fn () => $this->quick_facts[] = ['icon' => 'home-modern', 'label' => '', 'value' => ''];
$removeQuickFact = function (int $i) { unset($this->quick_facts[$i]); $this->quick_facts = array_values($this->quick_facts); };
$addInvestView = fn () => $this->investViews[] = '';
$removeInvestView = function (int $i) { unset($this->investViews[$i]); $this->investViews = array_values($this->investViews); };
$addGalleryItem = fn () => $this->galleryItems[] = '';
$removeGalleryItem = function (int $i) { unset($this->galleryItems[$i]); $this->galleryItems = array_values($this->galleryItems); };
$addLocPoint = fn () => $this->locPoints[] = '';
$removeLocPoint = function (int $i) { unset($this->locPoints[$i]); $this->locPoints = array_values($this->locPoints); };
$addContactOption = fn () => $this->contactOptions[] = ['key' => '', 'value' => ''];
$removeContactOption = function (int $i) { unset($this->contactOptions[$i]); $this->contactOptions = array_values($this->contactOptions); };
$addTrustColumn = fn () => $this->trustColumns[] = ['icon' => 'heroicon-o-lock-closed', 'title' => '', 'text' => ''];
$removeTrustColumn = function (int $i) { unset($this->trustColumns[$i]); $this->trustColumns = array_values($this->trustColumns); };
$save = function () {
$validated = validator([
'slug' => $this->slug,
'projectTitle' => $this->projectTitle,
'location' => $this->location,
'status' => $this->status,
'launch_date' => $this->launch_date,
'price_from_aed' => $this->price_from_aed,
'currency' => $this->currency,
'image' => $this->image,
'is_published' => $this->is_published,
'order' => $this->order,
], [
'slug' => 'required|string|max:255',
'projectTitle' => 'required|string|max:500',
'location' => 'nullable|string|max:500',
'status' => 'nullable|string|max:100',
'launch_date' => 'nullable|date',
'price_from_aed' => 'nullable|integer|min:0',
'currency' => 'nullable|string|max:10',
'image' => 'nullable|string|max:500',
'is_published' => 'boolean',
'order' => 'integer|min:0',
])->validate();
$project = $this->editingId
? CmsProject::findOrFail($this->editingId)
: CmsProject::query()->make();
$project->slug = $validated['slug'];
$project->setTranslation('title', $this->editLocale, $validated['projectTitle']);
$project->setTranslation('location', $this->editLocale, $validated['location'] ?? '');
$project->status = $validated['status'] ?? null;
$project->launch_date = $validated['launch_date'] ?: null;
$project->price_from_aed = $validated['price_from_aed'] ?: null;
$project->currency = $validated['currency'] ?? 'AED';
$project->image = $validated['image'] ?? null;
$project->is_published = $validated['is_published'];
$project->order = $validated['order'];
$project->setTranslation('highlights', $this->editLocale, array_values(array_filter($this->highlights, fn ($h) => trim($h) !== '')));
$project->quick_facts = collect($this->quick_facts)
->filter(fn ($f) => ! empty($f['label']) || ! empty($f['value']))
->values()
->toArray();
$project->setTranslation('investment_case', $this->editLocale, [
'title' => $this->investTitle,
'text' => $this->investText,
'views' => array_values(array_filter($this->investViews, fn ($v) => trim($v) !== '')),
]);
$project->gallery = array_values(array_filter($this->galleryItems, fn ($g) => trim($g) !== ''));
$project->setTranslation('location_info', $this->editLocale, [
'title' => $this->locTitle,
'map_url' => $this->locMapUrl,
'points' => array_values(array_filter($this->locPoints, fn ($p) => trim($p) !== '')),
]);
$opts = [];
foreach ($this->contactOptions as $opt) {
if ($opt['value'] !== '') {
$opts[$opt['key']] = $opt['value'];
}
}
$project->setTranslation('contact', $this->editLocale, [
'title' => $this->contactTitle,
'subtitle' => $this->contactSubtitle,
'options' => $opts,
]);
$trustCols = collect($this->trustColumns)
->filter(fn ($c) => ! empty(trim($c['title'] ?? '')) || ! empty(trim($c['text'] ?? '')))
->values()
->toArray();
$project->setTranslation('investor_trust', $this->editLocale, [
'title' => $this->trustTitle,
'intro' => $this->trustIntro,
'columns' => $trustCols,
'cta_url' => $this->trustCtaUrl,
'cta_label' => $this->trustCtaLabel,
]);
$project->setTranslation('furniture_benefit', $this->editLocale, [
'title' => $this->furnitureTitle,
'text' => $this->furnitureText,
'button_text' => $this->furnitureButtonText,
'button_link' => $this->furnitureButtonLink,
]);
$project->save();
$this->showForm = false;
$this->editingId = null;
Flux::toast(variant: 'success', heading: 'Gespeichert', text: $project->slug . ' wurde erfolgreich gespeichert.');
};
$deleteProject = function (int $id) {
$project = CmsProject::find($id);
if (! $project) {
return;
}
$slug = $project->slug;
$project->delete();
Flux::toast(variant: 'success', heading: 'Gelöscht', text: "{$slug} wurde entfernt.");
};
$cancelForm = function () {
$this->showForm = false;
$this->editingId = null;
};
$setActiveTab = function (string $tab): void {
$this->activeTab = $tab;
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<flux:heading size="xl">Projekte (Immobilien)</flux:heading>
<flux:button variant="primary" icon="plus" wire:click="openCreate">Neues Projekt</flux:button>
</div>
<div class="mb-4">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Projekt suchen..." icon="magnifying-glass"
size="sm" class="w-64" />
</div>
@if ($showForm)
<flux:card class="mb-6">
<div class="mb-4 flex items-center justify-between">
<flux:heading size="lg">{{ $editingId ? 'Projekt bearbeiten' : 'Neues Projekt' }}</flux:heading>
<div class="flex gap-1">
@foreach (config('flux-cms.locales', ['de' => 'DE', 'en' => 'EN']) as $code => $label)
<flux:button size="xs" :variant="$editLocale === $code ? 'primary' : 'ghost'"
wire:click="switchLocale('{{ $code }}')">{{ strtoupper($code) }}</flux:button>
@endforeach
</div>
</div>
{{-- Explizite Tab-Steuerung: zuverlässiger als nur wire:model auf ui-tabs (Livewire-Sync) --}}
<div class="mb-4 flex flex-wrap gap-1 rounded-lg bg-zinc-800/5 p-1 dark:bg-white/10">
<flux:button size="sm" type="button" :variant="$activeTab === 'basic' ? 'primary' : 'ghost'" icon="information-circle" wire:click="setActiveTab('basic')">Grunddaten</flux:button>
<flux:button size="sm" type="button" :variant="$activeTab === 'facts' ? 'primary' : 'ghost'" icon="list-bullet" wire:click="setActiveTab('facts')">Quick Facts</flux:button>
<flux:button size="sm" type="button" :variant="$activeTab === 'invest' ? 'primary' : 'ghost'" icon="chart-bar" wire:click="setActiveTab('invest')">Investment Case</flux:button>
<flux:button size="sm" type="button" :variant="$activeTab === 'gallery' ? 'primary' : 'ghost'" icon="photo" wire:click="setActiveTab('gallery')">Galerie</flux:button>
<flux:button size="sm" type="button" :variant="$activeTab === 'location' ? 'primary' : 'ghost'" icon="map-pin" wire:click="setActiveTab('location')">Location</flux:button>
<flux:button size="sm" type="button" :variant="$activeTab === 'contact' ? 'primary' : 'ghost'" icon="envelope" wire:click="setActiveTab('contact')">Kontakt</flux:button>
<flux:button size="sm" type="button" :variant="$activeTab === 'trust_synergy' ? 'primary' : 'ghost'" icon="shield-check" wire:click="setActiveTab('trust_synergy')">Trust & Möbel</flux:button>
</div>
<div class="mt-4" wire:key="project-form-panel-{{ $activeTab }}">
{{-- TAB: Grunddaten --}}
@if ($activeTab === 'basic')
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<flux:input wire:model="slug" label="Slug" placeholder="azizi-creek-views-4" />
<flux:input wire:model="projectTitle" label="Titel ({{ strtoupper($editLocale) }})" placeholder="Projektname" />
<flux:input wire:model="location" label="Standort ({{ strtoupper($editLocale) }})" placeholder="Dubai, UAE" />
<flux:input wire:model="status" label="Status" placeholder="z.B. NEW LAUNCH" />
<flux:input wire:model="launch_date" label="Launch-Datum" type="date" />
<flux:input wire:model="price_from_aed" label="Preis ab (AED)" type="number" placeholder="0" />
<flux:input wire:model="currency" label="Währung" placeholder="AED" />
<flux:input wire:model="order" label="Sortierung" type="number" />
<div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Hauptbild</label>
<div class="flex items-center gap-3">
@if ($image)
<div class="h-16 w-24 shrink-0 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<img src="{{ media_url($image) }}" class="h-full w-full object-cover" />
</div>
@endif
<div class="flex-1">
<livewire:admin.cms.media-picker :value="null" field="project_image" type="image" profile="card" label="Projektbild wählen" :key="'proj-img-' . ($editingId ?? 'new')" />
</div>
</div>
<flux:input wire:model="image" size="sm" class="mt-2" placeholder="Oder Dateiname manuell..." />
</div>
<div class="md:col-span-2">
<flux:heading size="sm" class="mb-2">Highlights ({{ strtoupper($editLocale) }})</flux:heading>
<div class="space-y-2">
@foreach ($highlights as $i => $hl)
<div wire:key="hl-{{ $i }}" class="flex items-center gap-2">
<flux:input wire:model="highlights.{{ $i }}" placeholder="Highlight-Text" class="flex-1" size="sm" />
@if (count($highlights) > 1)
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="removeHighlight({{ $i }})" />
@endif
</div>
@endforeach
</div>
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addHighlight" class="mt-2">Highlight</flux:button>
</div>
<div class="md:col-span-2">
<flux:switch wire:model="is_published" label="Veröffentlicht" />
</div>
</div>
@endif
{{-- TAB: Quick Facts --}}
@if ($activeTab === 'facts')
<div class="space-y-3">
@foreach ($quick_facts as $i => $fact)
<div wire:key="qf-{{ $i }}" class="flex items-end gap-2 rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
<flux:select wire:model="quick_facts.{{ $i }}.icon" label="Icon" class="w-44">
<flux:select.option value="home-modern">Haus</flux:select.option>
<flux:select.option value="squares-2x2">Fläche</flux:select.option>
<flux:select.option value="building-office-2">Gebäude</flux:select.option>
<flux:select.option value="user">Person</flux:select.option>
<flux:select.option value="currency-dollar">Preis</flux:select.option>
<flux:select.option value="calendar">Kalender</flux:select.option>
<flux:select.option value="map-pin">Standort</flux:select.option>
</flux:select>
<flux:input wire:model="quick_facts.{{ $i }}.label" label="Label" placeholder="Typen" class="flex-1" />
<flux:input wire:model="quick_facts.{{ $i }}.value" label="Wert" placeholder="1BR & 3BR" class="flex-1" />
@if (count($quick_facts) > 1)
<flux:button size="sm" variant="ghost" icon="trash" wire:click="removeQuickFact({{ $i }})" />
@endif
</div>
@endforeach
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addQuickFact">Quick Fact</flux:button>
</div>
@endif
{{-- TAB: Investment Case --}}
@if ($activeTab === 'invest')
<div class="space-y-4">
<flux:input wire:model="investTitle" label="Überschrift ({{ strtoupper($editLocale) }})" placeholder="Starkes Investment, hohe Nachfrage." />
<flux:textarea wire:model="investText" label="Text ({{ strtoupper($editLocale) }})" rows="4" placeholder="Beschreibung des Investment Case..." />
<div>
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Verfügbare Views</label>
<div class="space-y-2">
@foreach ($investViews as $i => $view)
<div wire:key="iv-{{ $i }}" class="flex items-center gap-2">
<flux:input wire:model="investViews.{{ $i }}" placeholder="z.B. Road View" class="flex-1" size="sm" />
@if (count($investViews) > 1)
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="removeInvestView({{ $i }})" />
@endif
</div>
@endforeach
</div>
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addInvestView" class="mt-2">View</flux:button>
</div>
</div>
@endif
{{-- TAB: Galerie --}}
@if ($activeTab === 'gallery')
<div class="space-y-3">
@foreach ($galleryItems as $i => $img)
<div wire:key="gal-{{ $i }}" class="flex items-center gap-3 rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
@if ($img)
<div class="h-14 w-20 shrink-0 overflow-hidden rounded-lg bg-zinc-100 dark:bg-zinc-700">
<img src="{{ media_url($img) }}" class="h-full w-full object-cover" />
</div>
@endif
<div class="flex-1">
<livewire:admin.cms.media-picker :value="null" :field="'gallery_' . $i" type="image" profile="gallery" label="Bild wählen" :key="'gal-picker-' . $i . '-' . ($editingId ?? 'new')" />
<flux:input wire:model="galleryItems.{{ $i }}" size="sm" class="mt-1" placeholder="Oder Pfad manuell..." />
</div>
@if (count($galleryItems) > 1)
<flux:button size="sm" variant="ghost" icon="trash" wire:click="removeGalleryItem({{ $i }})" />
@endif
</div>
@endforeach
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addGalleryItem">Bild hinzufügen</flux:button>
</div>
@endif
{{-- TAB: Location --}}
@if ($activeTab === 'location')
<div class="space-y-4">
<flux:input wire:model="locTitle" label="Überschrift ({{ strtoupper($editLocale) }})" placeholder="Strategische Location: Al Jaddaf" />
<flux:input wire:model="locMapUrl" label="Google Maps URL" placeholder="https://maps.google.com/?q=..." />
<div>
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Location-Punkte ({{ strtoupper($editLocale) }})</label>
<div class="space-y-2">
@foreach ($locPoints as $i => $point)
<div wire:key="lp-{{ $i }}" class="flex items-center gap-2">
<flux:input wire:model="locPoints.{{ $i }}" placeholder="Beschreibung des Standort-Vorteils" class="flex-1" size="sm" />
@if (count($locPoints) > 1)
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="removeLocPoint({{ $i }})" />
@endif
</div>
@endforeach
</div>
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addLocPoint" class="mt-2">Punkt</flux:button>
</div>
</div>
@endif
{{-- TAB: Kontakt --}}
@if ($activeTab === 'contact')
<div class="space-y-4">
<flux:input wire:model="contactTitle" label="Überschrift ({{ strtoupper($editLocale) }})" placeholder="Sichern Sie sich eine der Einheiten." />
<flux:input wire:model="contactSubtitle" label="Untertitel ({{ strtoupper($editLocale) }})" placeholder="Ihr Ansprechpartner: Marcel Scheibe" />
<div>
<label class="mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Auswahloptionen (Key Anzeige)</label>
<div class="space-y-2">
@foreach ($contactOptions as $i => $opt)
<div wire:key="co-{{ $i }}" class="flex items-center gap-2">
<flux:input wire:model="contactOptions.{{ $i }}.key" placeholder="Key (leer=Default)" class="w-40" size="sm" />
<flux:input wire:model="contactOptions.{{ $i }}.value" placeholder="Anzeige-Text" class="flex-1" size="sm" />
@if (count($contactOptions) > 1)
<flux:button size="xs" variant="ghost" icon="x-mark" wire:click="removeContactOption({{ $i }})" />
@endif
</div>
@endforeach
</div>
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addContactOption" class="mt-2">Option</flux:button>
</div>
</div>
@endif
{{-- TAB: Trust-Block & Möbel-Vorteil (Detailseite) --}}
@if ($activeTab === 'trust_synergy')
<div class="space-y-8">
<div>
<flux:heading size="sm" class="mb-3">Trust-Block: Investorenschutz ({{ strtoupper($editLocale) }})</flux:heading>
<div class="space-y-4">
<flux:input wire:model="trustTitle" label="Überschrift" placeholder="Maximale Sicherheit für Ihr Investment" />
<flux:textarea wire:model="trustIntro" label="Einleitung" rows="2" placeholder="Kurzer Intro-Text unter der Überschrift" />
<flux:input wire:model="trustCtaUrl" label="CTA-Link (Magazin o. ä.)" placeholder="/magazin/1" />
<flux:input wire:model="trustCtaLabel" label="CTA-Button-Text" placeholder="Deep Dive: …" />
<div>
<label class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-300">Drei Spalten (Icon = Blade-Name, z. B. heroicon-o-lock-closed)</label>
<div class="space-y-4">
@foreach ($trustColumns as $ti => $tc)
<div wire:key="trust-col-{{ $ti }}" class="rounded-lg border border-zinc-200 p-4 dark:border-zinc-700">
<div class="mb-3 flex items-end gap-2">
<flux:select wire:model="trustColumns.{{ $ti }}.icon" label="Icon" class="min-w-[14rem]">
<flux:select.option value="heroicon-o-lock-closed">Schloss (Escrow)</flux:select.option>
<flux:select.option value="heroicon-o-building-library">Gebäude / DLD</flux:select.option>
<flux:select.option value="heroicon-o-chart-bar">Diagramm</flux:select.option>
<flux:select.option value="heroicon-o-shield-check">Schild</flux:select.option>
<flux:select.option value="heroicon-o-sparkles">Sparkles</flux:select.option>
</flux:select>
@if (count($trustColumns) > 1)
<flux:button size="sm" variant="ghost" icon="trash" wire:click="removeTrustColumn({{ $ti }})" />
@endif
</div>
<flux:input wire:model="trustColumns.{{ $ti }}.title" label="Spaltenüberschrift" class="mb-2" />
<flux:textarea wire:model="trustColumns.{{ $ti }}.text" label="Text" rows="3" />
</div>
@endforeach
</div>
<flux:button size="xs" variant="ghost" icon="plus" wire:click="addTrustColumn" class="mt-2">Spalte hinzufügen</flux:button>
</div>
</div>
</div>
<flux:separator />
<div>
<flux:heading size="sm" class="mb-3">Möbel-Vorteil / Synergie ({{ strtoupper($editLocale) }})</flux:heading>
<flux:textarea wire:model="furnitureTitle" label="Überschrift (HTML erlaubt, z. B. &lt;span class=&quot;text-secondary&quot;&gt;)" rows="2" />
<flux:textarea wire:model="furnitureText" label="Fließtext" rows="4" class="mt-3" />
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<flux:input wire:model="furnitureButtonText" label="Button-Text" placeholder="Mehr zum B2in-Netzwerk" />
<flux:input wire:model="furnitureButtonLink" label="Button-Link" placeholder="/netzwerk" />
</div>
</div>
</div>
@endif
</div>
<div class="mt-6 flex gap-2 border-t border-zinc-200 pt-4 dark:border-zinc-700">
<flux:button variant="primary" wire:click="save">Speichern</flux:button>
<flux:button variant="ghost" wire:click="cancelForm">Abbrechen</flux:button>
</div>
</flux:card>
@endif
{{-- Projektliste --}}
<flux:card>
<div class="divide-y dark:divide-zinc-700">
@forelse ($this->projects as $project)
<div wire:key="project-{{ $project->id }}" class="flex items-center justify-between gap-4 py-3">
<div class="flex items-center gap-4">
@if ($project->image)
<div class="h-12 w-20 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($project->image) }}" class="h-full w-full object-cover" loading="lazy" />
</div>
@else
<div class="flex h-12 w-20 shrink-0 items-center justify-center rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800">
<x-heroicon-o-building-office class="h-6 w-6 text-zinc-300" />
</div>
@endif
<div>
<div class="flex items-center gap-2">
<span class="font-medium text-zinc-800 dark:text-zinc-200">{{ $project->title }}</span>
@if ($project->is_published)
<flux:badge size="sm" color="green">Live</flux:badge>
@else
<flux:badge size="sm" color="zinc">Entwurf</flux:badge>
@endif
</div>
<div class="flex items-center gap-3 text-sm text-zinc-500">
<span>{{ $project->location }}</span>
@if ($project->status)
<span>· {{ $project->status }}</span>
@endif
@if ($project->getFormattedPrice())
<span>· {{ $project->getFormattedPrice() }}</span>
@endif
</div>
</div>
</div>
<div class="flex items-center gap-1">
<flux:button size="sm" variant="ghost" icon="pencil" wire:click="openEdit({{ $project->id }})" />
<flux:button size="sm" variant="ghost" icon="document-duplicate" wire:click="duplicateProject({{ $project->id }})" title="Projekt duplizieren" />
<flux:button size="sm" variant="ghost" icon="trash"
wire:click="deleteProject({{ $project->id }})"
wire:confirm="'{{ $project->title }}' wirklich löschen?" />
</div>
</div>
@empty
<div class="py-12 text-center">
<x-heroicon-o-building-office class="mx-auto mb-4 h-12 w-12 text-zinc-400" />
<flux:heading>Keine Projekte</flux:heading>
<flux:text>Erstelle das erste Projekt mit dem Button oben.</flux:text>
</div>
@endforelse
</div>
</flux:card>
</div>