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,45 @@
<?php
namespace App\Livewire\Admin\Cms;
use FluxCms\Core\Services\MediaConversionService;
use Livewire\Component;
use Livewire\WithFileUploads;
class MediaLibraryUploader extends Component
{
use WithFileUploads;
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
public array $uploads = [];
public function updatedUploads(): void
{
$this->validate([
'uploads' => 'nullable|array|max:20',
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
]);
$service = app(MediaConversionService::class);
foreach ($this->uploads as $file) {
$media = $service->storeUpload($file);
$this->dispatch('media-library-uploaded', mediaId: $media->id);
}
$this->uploads = [];
}
public function removeUpload(int $index): void
{
if (isset($this->uploads[$index])) {
unset($this->uploads[$index]);
$this->uploads = array_values($this->uploads);
}
}
public function render()
{
return view('livewire.admin.cms.media-library-uploader');
}
}

View file

@ -0,0 +1,139 @@
<?php
namespace App\Livewire\Admin\Cms;
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\MediaConversionService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\View\View;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\WithPagination;
class MediaPicker extends Component
{
use WithFileUploads;
use WithPagination;
public ?int $value = null;
public string $field = 'media_id';
public string $type = 'image';
public string $profile = 'thumbnail';
public string $label = 'Bild auswählen';
public bool $showModal = false;
public string $search = '';
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
public array $quickUploads = [];
public function mount(?int $value = null): void
{
$this->value = $value;
}
public function openPicker(): void
{
$this->showModal = true;
}
public function selectMedia(int $id): void
{
$media = CmsMedia::find($id);
if (! $media) {
return;
}
if ($media->isImage() && $this->profile) {
$service = app(MediaConversionService::class);
if (! $media->hasConversion($this->profile)) {
$service->convert($media, $this->profile);
$media->refresh();
}
}
$this->value = $media->id;
$this->showModal = false;
$this->dispatch('media-selected', field: $this->field, mediaId: $media->id, url: $media->getConversionUrl($this->profile));
}
public function clearSelection(): void
{
$this->value = null;
$this->dispatch('media-selected', field: $this->field, mediaId: null, url: null);
}
public function updatedQuickUploads(): void
{
$this->validate([
'quickUploads' => 'nullable|array|max:5',
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
]);
$service = app(MediaConversionService::class);
$lastMedia = null;
foreach ($this->quickUploads as $file) {
$lastMedia = $service->storeUpload($file);
if ($lastMedia->isImage() && $this->profile) {
$service->convert($lastMedia, $this->profile);
$lastMedia->refresh();
}
}
$this->quickUploads = [];
if ($lastMedia) {
$this->value = $lastMedia->id;
$this->showModal = false;
$this->dispatch('media-selected', field: $this->field, mediaId: $lastMedia->id, url: $lastMedia->getConversionUrl($this->profile));
}
}
public function removeQuickUpload(int $index): void
{
if (isset($this->quickUploads[$index])) {
unset($this->quickUploads[$index]);
$this->quickUploads = array_values($this->quickUploads);
}
}
public function render(): View
{
return view('livewire.admin.cms.media-picker', [
'selectedMedia' => $this->resolveSelectedMedia(),
'mediaItems' => $this->resolveMediaItems(),
]);
}
private function resolveSelectedMedia(): ?CmsMedia
{
if (! $this->value) {
return null;
}
return CmsMedia::find($this->value);
}
/**
* @return LengthAwarePaginator<int, CmsMedia>
*/
private function resolveMediaItems(): LengthAwarePaginator
{
return CmsMedia::query()
->when($this->type === 'image', fn ($q) => $q->images())
->when($this->type === 'pdf', fn ($q) => $q->pdfs())
->when($this->type === 'document', fn ($q) => $q->documents())
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%"))
->orderByDesc('created_at')
->paginate(18);
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Admin\Cms;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
class MediaUploader extends Component
{
use WithFileUploads;
public string $field = '';
public string $accept = 'image/*';
public string $disk = 'public';
public string $directory = 'cms/uploads';
#[Validate('file|max:10240')]
public $file;
public function updatedFile(): void
{
$this->validate();
$path = $this->file->store($this->directory, $this->disk);
$this->dispatch('media-uploaded', field: $this->field, path: '/storage/'.$path);
$this->file = null;
}
public function render()
{
return view('livewire.admin.cms.media-uploader');
}
}

View file

@ -0,0 +1,302 @@
<?php
namespace App\Livewire\Admin\Cms;
use App\Models\DisplayFooterContent;
use App\Models\DisplayVideo;
use Illuminate\Support\Facades\File;
use Livewire\Component;
class CabinetDisplay extends Component
{
// Video-Verwaltung
public $videoId = null;
public $videoFilename = '';
public $videoTitle = '';
public $videoPosition = 25;
public $videoIsActive = true;
public $showVideoModal = false;
public $availableVideos = [];
// Footer-Content-Verwaltung
public $footerId = null;
public $footerHeadline = '';
public $footerSubline = '';
public $footerUrl = '';
public $footerIsActive = true;
public $showFooterModal = false;
public function mount()
{
$this->loadAvailableVideos();
}
/**
* Lädt alle verfügbaren Video-Dateien aus dem assets-Ordner
*/
public function loadAvailableVideos()
{
$assetsPath = public_path('_cabinet/assets');
if (File::exists($assetsPath)) {
$files = File::files($assetsPath);
$this->availableVideos = collect($files)
->map(fn ($file) => $file->getFilename())
->filter(fn ($filename) => in_array(pathinfo($filename, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov']))
->values()
->toArray();
}
}
// ========================================
// VIDEO-VERWALTUNG
// ========================================
public function openVideoModal($id = null)
{
if ($id) {
$video = DisplayVideo::findOrFail($id);
$this->videoId = $video->id;
$this->videoFilename = $video->filename;
$this->videoTitle = $video->title ?? '';
$this->videoPosition = $video->position;
$this->videoIsActive = $video->is_active;
} else {
$this->resetVideoForm();
}
$this->showVideoModal = true;
}
public function saveVideo()
{
$this->validate([
'videoFilename' => 'required|string',
'videoPosition' => 'required|integer|min:0|max:100',
], [
'videoFilename.required' => 'Bitte wählen Sie ein Video aus.',
'videoPosition.required' => 'Die Position ist erforderlich.',
'videoPosition.min' => 'Die Position muss zwischen 0 und 100 liegen.',
'videoPosition.max' => 'Die Position muss zwischen 0 und 100 liegen.',
]);
$data = [
'filename' => $this->videoFilename,
'title' => $this->videoTitle,
'position' => $this->videoPosition,
'is_active' => $this->videoIsActive,
];
if ($this->videoId) {
$video = DisplayVideo::findOrFail($this->videoId);
$video->update($data);
session()->flash('success', 'Video erfolgreich aktualisiert!');
} else {
$maxSortOrder = DisplayVideo::max('sort_order') ?? 0;
$data['sort_order'] = $maxSortOrder + 1;
DisplayVideo::create($data);
session()->flash('success', 'Video erfolgreich hinzugefügt!');
}
$this->closeVideoModal();
}
public function deleteVideo($id)
{
DisplayVideo::findOrFail($id)->delete();
session()->flash('success', 'Video erfolgreich gelöscht!');
}
public function toggleVideoStatus($id)
{
$video = DisplayVideo::findOrFail($id);
$video->update(['is_active' => ! $video->is_active]);
}
public function moveVideo($id, $direction)
{
$video = DisplayVideo::findOrFail($id);
$currentOrder = $video->sort_order;
if ($direction === 'up' && $currentOrder > 0) {
$swapVideo = DisplayVideo::where('sort_order', $currentOrder - 1)->first();
if ($swapVideo) {
$video->update(['sort_order' => $currentOrder - 1]);
$swapVideo->update(['sort_order' => $currentOrder]);
}
} elseif ($direction === 'down') {
$swapVideo = DisplayVideo::where('sort_order', $currentOrder + 1)->first();
if ($swapVideo) {
$video->update(['sort_order' => $currentOrder + 1]);
$swapVideo->update(['sort_order' => $currentOrder]);
}
}
}
public function resetVideoForm()
{
$this->videoId = null;
$this->videoFilename = '';
$this->videoTitle = '';
$this->videoPosition = 25;
$this->videoIsActive = true;
}
public function closeVideoModal()
{
$this->showVideoModal = false;
$this->resetVideoForm();
}
// ========================================
// FOOTER-CONTENT-VERWALTUNG
// ========================================
public function openFooterModal($id = null)
{
if ($id) {
$footer = DisplayFooterContent::findOrFail($id);
$this->footerId = $footer->id;
$this->footerHeadline = $footer->headline;
$this->footerSubline = $footer->subline;
$this->footerUrl = $footer->url;
$this->footerIsActive = $footer->is_active;
} else {
$this->resetFooterForm();
}
$this->showFooterModal = true;
}
public function saveFooter()
{
$this->validate([
'footerHeadline' => 'required|string|max:255',
'footerSubline' => 'required|string|max:255',
'footerUrl' => 'nullable|url',
], [
'footerHeadline.required' => 'Die Überschrift ist erforderlich.',
'footerSubline.required' => 'Die Unterzeile ist erforderlich.',
'footerUrl.url' => 'Bitte geben Sie eine gültige URL ein.',
]);
$data = [
'headline' => $this->footerHeadline,
'subline' => $this->footerSubline,
'url' => $this->footerUrl ?: null,
'is_active' => $this->footerIsActive,
];
if ($this->footerId) {
$footer = DisplayFooterContent::findOrFail($this->footerId);
$footer->update($data);
// Short-Code generieren falls URL vorhanden aber noch kein Short-Code
if ($footer->url && ! $footer->short_code) {
$footer->short_code = DisplayFooterContent::generateUniqueShortCode();
$footer->save();
}
session()->flash('success', 'Footer-Inhalt erfolgreich aktualisiert!');
} else {
$maxSortOrder = DisplayFooterContent::max('sort_order') ?? 0;
$data['sort_order'] = $maxSortOrder + 1;
$footer = DisplayFooterContent::create($data);
// Short-Code nur generieren wenn URL vorhanden
if ($footer->url) {
$footer->short_code = DisplayFooterContent::generateUniqueShortCode();
$footer->save();
session()->flash('success', 'Footer-Inhalt erfolgreich hinzugefügt! Short-Link: '.$footer->short_url);
} else {
session()->flash('success', 'Footer-Inhalt erfolgreich hinzugefügt! (Ohne QR-Code)');
}
}
$this->closeFooterModal();
}
public function regenerateShortCode($id)
{
$footer = DisplayFooterContent::findOrFail($id);
$footer->short_code = DisplayFooterContent::generateUniqueShortCode();
$footer->save();
session()->flash('success', 'Short-Code wurde neu generiert!');
}
public function resetClicks($id)
{
$footer = DisplayFooterContent::findOrFail($id);
$footer->clicks = 0;
$footer->save();
session()->flash('success', 'Klick-Zähler wurde zurückgesetzt!');
}
public function deleteFooter($id)
{
DisplayFooterContent::findOrFail($id)->delete();
session()->flash('success', 'Footer-Inhalt erfolgreich gelöscht!');
}
public function toggleFooterStatus($id)
{
$footer = DisplayFooterContent::findOrFail($id);
$footer->update(['is_active' => ! $footer->is_active]);
}
public function moveFooter($id, $direction)
{
$footer = DisplayFooterContent::findOrFail($id);
$currentOrder = $footer->sort_order;
if ($direction === 'up' && $currentOrder > 0) {
$swapFooter = DisplayFooterContent::where('sort_order', $currentOrder - 1)->first();
if ($swapFooter) {
$footer->update(['sort_order' => $currentOrder - 1]);
$swapFooter->update(['sort_order' => $currentOrder]);
}
} elseif ($direction === 'down') {
$swapFooter = DisplayFooterContent::where('sort_order', $currentOrder + 1)->first();
if ($swapFooter) {
$footer->update(['sort_order' => $currentOrder + 1]);
$swapFooter->update(['sort_order' => $currentOrder]);
}
}
}
public function resetFooterForm()
{
$this->footerId = null;
$this->footerHeadline = '';
$this->footerSubline = '';
$this->footerUrl = '';
$this->footerIsActive = true;
}
public function closeFooterModal()
{
$this->showFooterModal = false;
$this->resetFooterForm();
}
public function render()
{
$videos = DisplayVideo::orderBy('sort_order')->get();
$footerContents = DisplayFooterContent::orderBy('sort_order')->get();
return view('livewire.admin.cms.cabinet-display', [
'videos' => $videos,
'footerContents' => $footerContents,
]);
}
}

View file

@ -0,0 +1,189 @@
<?php
namespace App\Livewire\Admin\Cms;
use App\Models\CabinetTabletSetting;
use Livewire\Component;
class CabinetInfoTablet extends Component
{
// Store status mode
public string $storeStatus = 'auto';
public string $noticeHeadline = '';
public string $noticeSubtext = '';
// Override times for today
public ?string $overrideOpenToday = '';
public ?string $overrideCloseToday = '';
// Appointment
public ?string $nextAppointmentDate = null;
public ?string $nextAppointmentTime = '';
// Structured opening hours per weekday (open + close, empty = closed)
public ?string $hoursMondayOpen = '10:00';
public ?string $hoursMondayClose = '18:00';
public ?string $hoursTuesdayOpen = '10:00';
public ?string $hoursTuesdayClose = '18:00';
public ?string $hoursWednesdayOpen = '10:00';
public ?string $hoursWednesdayClose = '18:00';
public ?string $hoursThursdayOpen = '10:00';
public ?string $hoursThursdayClose = '18:00';
public ?string $hoursFridayOpen = '10:00';
public ?string $hoursFridayClose = '18:00';
public ?string $hoursSaturdayOpen = '10:00';
public ?string $hoursSaturdayClose = '14:00';
public ?string $hoursSundayOpen = '';
public ?string $hoursSundayClose = '';
// Contact
public string $contactPhone = '';
public string $contactEmail = '';
public function mount(): void
{
$s = CabinetTabletSetting::current();
$this->storeStatus = $s->store_status ?? 'auto';
$this->noticeHeadline = $s->notice_headline ?? '';
$this->noticeSubtext = $s->notice_subtext ?? '';
$this->overrideOpenToday = $s->override_open_today ?? '';
$this->overrideCloseToday = $s->override_close_today ?? '';
$this->nextAppointmentDate = $s->next_appointment_date?->format('Y-m-d');
$this->nextAppointmentTime = $s->next_appointment_time ?? '';
$this->hoursMondayOpen = $s->hours_monday_open ?? '';
$this->hoursMondayClose = $s->hours_monday_close ?? '';
$this->hoursTuesdayOpen = $s->hours_tuesday_open ?? '';
$this->hoursTuesdayClose = $s->hours_tuesday_close ?? '';
$this->hoursWednesdayOpen = $s->hours_wednesday_open ?? '';
$this->hoursWednesdayClose = $s->hours_wednesday_close ?? '';
$this->hoursThursdayOpen = $s->hours_thursday_open ?? '';
$this->hoursThursdayClose = $s->hours_thursday_close ?? '';
$this->hoursFridayOpen = $s->hours_friday_open ?? '';
$this->hoursFridayClose = $s->hours_friday_close ?? '';
$this->hoursSaturdayOpen = $s->hours_saturday_open ?? '';
$this->hoursSaturdayClose = $s->hours_saturday_close ?? '';
$this->hoursSundayOpen = $s->hours_sunday_open ?? '';
$this->hoursSundayClose = $s->hours_sunday_close ?? '';
$this->contactPhone = $s->contact_phone ?? '';
$this->contactEmail = $s->contact_email ?? '';
}
private function timeRule(): array
{
return ['nullable', 'string', 'regex:/^(\d{2}:\d{2})?$/'];
}
private function toNullIfEmpty(?string $value): ?string
{
return $value !== null && trim($value) !== '' ? trim($value) : null;
}
/**
* @param array<string, string> $hours Optional: Time picker values from DOM (bypasses wire:model sync issues)
*/
public function save(array $hours = []): void
{
foreach ($hours as $prop => $value) {
if (property_exists($this, $prop)) {
$this->{$prop} = $value ?? '';
}
}
$timeRule = $this->timeRule();
$this->validate([
'storeStatus' => 'required|in:auto,notice,warning,closed',
'noticeHeadline' => 'nullable|string|max:40',
'noticeSubtext' => 'nullable|string|max:80',
'overrideOpenToday' => $timeRule,
'overrideCloseToday' => $timeRule,
'nextAppointmentDate' => 'nullable|date',
'nextAppointmentTime' => $timeRule,
'hoursMondayOpen' => $timeRule,
'hoursMondayClose' => $timeRule,
'hoursTuesdayOpen' => $timeRule,
'hoursTuesdayClose' => $timeRule,
'hoursWednesdayOpen' => $timeRule,
'hoursWednesdayClose' => $timeRule,
'hoursThursdayOpen' => $timeRule,
'hoursThursdayClose' => $timeRule,
'hoursFridayOpen' => $timeRule,
'hoursFridayClose' => $timeRule,
'hoursSaturdayOpen' => $timeRule,
'hoursSaturdayClose' => $timeRule,
'hoursSundayOpen' => $timeRule,
'hoursSundayClose' => $timeRule,
'contactPhone' => 'nullable|string|max:50',
'contactEmail' => 'nullable|email|max:100',
], [
'storeStatus.required' => 'Der Store-Status ist erforderlich.',
'storeStatus.in' => 'Ungültiger Status. Erlaubt: auto, notice, warning, closed.',
'noticeHeadline.max' => 'Die Headline darf maximal 40 Zeichen haben.',
'noticeSubtext.max' => 'Der Subtext darf maximal 80 Zeichen haben.',
'overrideOpenToday.regex' => 'Bitte im Format HH:MM eingeben.',
'overrideCloseToday.regex' => 'Bitte im Format HH:MM eingeben.',
'nextAppointmentTime.regex' => 'Bitte im Format HH:MM eingeben.',
'contactEmail.email' => 'Bitte eine gültige E-Mail-Adresse eingeben.',
]);
CabinetTabletSetting::current()->update([
'store_status' => $this->storeStatus,
'notice_headline' => $this->toNullIfEmpty($this->noticeHeadline),
'notice_subtext' => $this->toNullIfEmpty($this->noticeSubtext),
'override_open_today' => $this->toNullIfEmpty($this->overrideOpenToday),
'override_close_today' => $this->toNullIfEmpty($this->overrideCloseToday),
'next_appointment_date' => $this->toNullIfEmpty($this->nextAppointmentDate),
'next_appointment_time' => $this->toNullIfEmpty($this->nextAppointmentTime),
'hours_monday_open' => $this->toNullIfEmpty($this->hoursMondayOpen),
'hours_monday_close' => $this->toNullIfEmpty($this->hoursMondayClose),
'hours_tuesday_open' => $this->toNullIfEmpty($this->hoursTuesdayOpen),
'hours_tuesday_close' => $this->toNullIfEmpty($this->hoursTuesdayClose),
'hours_wednesday_open' => $this->toNullIfEmpty($this->hoursWednesdayOpen),
'hours_wednesday_close' => $this->toNullIfEmpty($this->hoursWednesdayClose),
'hours_thursday_open' => $this->toNullIfEmpty($this->hoursThursdayOpen),
'hours_thursday_close' => $this->toNullIfEmpty($this->hoursThursdayClose),
'hours_friday_open' => $this->toNullIfEmpty($this->hoursFridayOpen),
'hours_friday_close' => $this->toNullIfEmpty($this->hoursFridayClose),
'hours_saturday_open' => $this->toNullIfEmpty($this->hoursSaturdayOpen),
'hours_saturday_close' => $this->toNullIfEmpty($this->hoursSaturdayClose),
'hours_sunday_open' => $this->toNullIfEmpty($this->hoursSundayOpen),
'hours_sunday_close' => $this->toNullIfEmpty($this->hoursSundayClose),
'contact_phone' => $this->toNullIfEmpty($this->contactPhone),
'contact_email' => $this->toNullIfEmpty($this->contactEmail),
]);
session()->flash('success', 'Info-Tablet Einstellungen gespeichert!');
}
public function clearOverrides(): void
{
CabinetTabletSetting::current()->clearOverrides();
$this->overrideOpenToday = '';
$this->overrideCloseToday = '';
session()->flash('success', 'Sonderöffnungszeiten wurden zurückgesetzt!');
}
public function render(): \Illuminate\View\View
{
return view('livewire.admin.cms.cabinet-info-tablet');
}
}

View file

@ -0,0 +1,153 @@
<?php
namespace App\Livewire\Admin\Cms;
use App\Models\Display;
use App\Models\DisplayVersion;
use Livewire\Component;
class DisplayList extends Component
{
public $showModal = false;
public $displayId = null;
public $displayName = '';
public $displayLocation = '';
/** @var array<int> */
public $selectedVersionIds = [];
public $displayIsActive = true;
public $addVersionSelect = null;
public function openModal(?int $id = null): void
{
if ($id) {
$display = Display::with('versions')->findOrFail($id);
$this->displayId = $display->id;
$this->displayName = $display->name;
$this->displayLocation = $display->location ?? '';
$this->selectedVersionIds = $display->versions->pluck('id')->toArray();
$this->displayIsActive = $display->is_active;
} else {
$this->resetForm();
}
$this->showModal = true;
}
public function addVersion(?int $versionId = null): void
{
$id = $versionId ?? $this->addVersionSelect;
if ($id && ! in_array((int) $id, $this->selectedVersionIds)) {
$this->selectedVersionIds[] = (int) $id;
}
$this->addVersionSelect = null;
}
public function removeVersion(int $index): void
{
array_splice($this->selectedVersionIds, $index, 1);
}
public function moveVersion(int $index, string $direction): void
{
$newIndex = $direction === 'up' ? $index - 1 : $index + 1;
if ($newIndex < 0 || $newIndex >= count($this->selectedVersionIds)) {
return;
}
$temp = $this->selectedVersionIds[$index];
$this->selectedVersionIds[$index] = $this->selectedVersionIds[$newIndex];
$this->selectedVersionIds[$newIndex] = $temp;
}
public function save(): void
{
$this->validate([
'displayName' => 'required|string|max:255',
'displayLocation' => 'nullable|string|max:255',
'selectedVersionIds' => 'array',
'selectedVersionIds.*' => 'exists:display_versions,id',
], [
'displayName.required' => 'Bitte geben Sie einen Namen ein.',
]);
$data = [
'name' => $this->displayName,
'location' => $this->displayLocation ?: null,
'is_active' => $this->displayIsActive,
];
if ($this->displayId) {
$display = Display::findOrFail($this->displayId);
$display->update($data);
session()->flash('success', 'Display erfolgreich aktualisiert!');
} else {
$display = Display::create($data);
session()->flash('success', 'Display erfolgreich erstellt!');
}
// Sync versions with sort_order
$syncData = [];
foreach ($this->selectedVersionIds as $sortOrder => $versionId) {
$syncData[$versionId] = ['sort_order' => $sortOrder];
}
$display->versions()->sync($syncData);
$this->closeModal();
}
public function deleteDisplay(int $id): void
{
$display = Display::findOrFail($id);
$name = $display->name;
$display->delete();
session()->flash('success', 'Display "'.$name.'" wurde gelöscht!');
}
public function toggleActive(int $id): void
{
$display = Display::findOrFail($id);
$display->update(['is_active' => ! $display->is_active]);
}
public function closeModal(): void
{
$this->showModal = false;
$this->resetForm();
}
public function resetForm(): void
{
$this->displayId = null;
$this->displayName = '';
$this->displayLocation = '';
$this->selectedVersionIds = [];
$this->displayIsActive = true;
$this->addVersionSelect = null;
}
public function render()
{
$displays = Display::with('versions')
->orderBy('name')
->get();
$versions = DisplayVersion::active()
->orderBy('name')
->get();
return view('livewire.admin.cms.display-list', [
'displays' => $displays,
'versions' => $versions,
]);
}
}

View file

@ -0,0 +1,437 @@
<?php
namespace App\Livewire\Admin\Cms;
use App\Enums\DisplayVersionType;
use App\Models\DisplayVersion;
use App\Models\DisplayVersionItem;
use Illuminate\Support\Facades\File;
use Livewire\Component;
class DisplayVersionEditor extends Component
{
public DisplayVersion $version;
public string $versionName = '';
// Item Modal
public bool $showItemModal = false;
public ?int $itemId = null;
public string $itemType = '';
// Video-Display: Video fields
public string $videoFilename = '';
public string $videoTitle = '';
public int $videoPosition = 25;
public bool $videoIsActive = true;
// Video-Display: Footer fields
public string $footerHeadline = '';
public string $footerSubline = '';
public string $footerUrl = '';
public bool $footerIsActive = true;
// B2in: Media fields
public string $mediaType = 'image';
public string $mediaCategory = 'immobilien';
public string $mediaUrl = '';
public string $mediaHeadline = '';
public string $mediaSubline = '';
public int $mediaDuration = 10;
public bool $mediaIsActive = true;
// Offers: Slide fields
public string $slideType = 'product-hero';
public int $slideDuration = 8000;
public string $slideImageUrl = '';
public string $slideBadge = '';
public string $slideEyebrow = '';
public string $slideTitle = '';
public string $slideSubline = '';
public string $slidePrice = '';
public string $slideOriginalPrice = '';
public string $slideTagText = '';
/** @var array<string> */
public array $slideBullets = [];
public string $slideDisclaimer = '';
public string $slideQrUrl = '';
public string $slideQrTitle = '';
public string $slideContact = '';
public bool $slideShowBrandText = false;
public string $slideBrandTagline = '';
public bool $slideIsActive = true;
// Settings Modal
public bool $showSettingsModal = false;
public array $settings = [];
/** @var array<string> */
public array $availableVideos = [];
public function mount(DisplayVersion $displayVersion): void
{
$this->version = $displayVersion;
$this->versionName = $displayVersion->name;
$this->settings = $displayVersion->settings ?? [];
if ($this->version->type === DisplayVersionType::VideoDisplay) {
$this->loadAvailableVideos();
}
}
public function loadAvailableVideos(): void
{
$assetsPath = public_path('_cabinet/assets');
if (File::exists($assetsPath)) {
$this->availableVideos = collect(File::files($assetsPath))
->map(fn ($file) => $file->getFilename())
->filter(fn ($filename) => in_array(pathinfo($filename, PATHINFO_EXTENSION), ['mp4', 'webm', 'mov']))
->values()
->toArray();
}
}
public function toggleTheme(): void
{
$settings = $this->version->settings ?? [];
$settings['theme'] = ($settings['theme'] ?? 'dark') === 'dark' ? 'light' : 'dark';
$this->version->update(['settings' => $settings]);
$this->settings = $settings;
}
public function saveName(): void
{
$this->validate([
'versionName' => 'required|string|max:255',
]);
$this->version->update(['name' => $this->versionName]);
session()->flash('success', 'Name aktualisiert!');
}
// ========================================
// SETTINGS
// ========================================
public function openSettingsModal(): void
{
$this->settings = $this->version->settings ?? [];
$this->showSettingsModal = true;
}
public function saveSettings(): void
{
$this->version->update(['settings' => $this->settings]);
$this->showSettingsModal = false;
session()->flash('success', 'Einstellungen gespeichert!');
}
// ========================================
// ITEM CRUD
// ========================================
public function openItemModal(?int $id = null, string $type = ''): void
{
$this->resetItemForm();
if ($id) {
$item = DisplayVersionItem::findOrFail($id);
$this->itemId = $item->id;
$this->itemType = $item->item_type;
$this->loadItemContent($item);
} else {
$this->itemType = $type ?: $this->defaultItemType();
}
$this->showItemModal = true;
}
public function saveItem(): void
{
$content = $this->buildItemContent();
$isActive = $this->getActiveFlag();
if ($this->itemId) {
$item = DisplayVersionItem::findOrFail($this->itemId);
$item->update([
'content' => $content,
'is_active' => $isActive,
]);
session()->flash('success', 'Inhalt aktualisiert!');
} else {
$maxSort = DisplayVersionItem::where('display_version_id', $this->version->id)
->where('item_type', $this->itemType)
->max('sort_order') ?? -1;
DisplayVersionItem::create([
'display_version_id' => $this->version->id,
'item_type' => $this->itemType,
'content' => $content,
'sort_order' => $maxSort + 1,
'is_active' => $isActive,
]);
session()->flash('success', 'Inhalt hinzugefügt!');
}
$this->closeItemModal();
}
public function deleteItem(int $id): void
{
DisplayVersionItem::findOrFail($id)->delete();
session()->flash('success', 'Inhalt gelöscht!');
}
public function toggleItemStatus(int $id): void
{
$item = DisplayVersionItem::findOrFail($id);
$item->update(['is_active' => ! $item->is_active]);
}
public function moveItem(int $id, string $direction): void
{
$item = DisplayVersionItem::findOrFail($id);
$currentOrder = $item->sort_order;
$swapItem = DisplayVersionItem::where('display_version_id', $this->version->id)
->where('item_type', $item->item_type)
->where('sort_order', $direction === 'up' ? $currentOrder - 1 : $currentOrder + 1)
->first();
if ($swapItem) {
$item->update(['sort_order' => $swapItem->sort_order]);
$swapItem->update(['sort_order' => $currentOrder]);
}
}
public function addBullet(): void
{
$this->slideBullets[] = '';
}
public function removeBullet(int $index): void
{
unset($this->slideBullets[$index]);
$this->slideBullets = array_values($this->slideBullets);
}
public function closeItemModal(): void
{
$this->showItemModal = false;
$this->resetItemForm();
}
// ========================================
// HELPERS
// ========================================
private function loadItemContent(DisplayVersionItem $item): void
{
$content = $item->content;
match ($item->item_type) {
'video' => $this->loadVideoContent($content),
'footer' => $this->loadFooterContent($content),
'media' => $this->loadMediaContent($content),
'slide' => $this->loadSlideContent($content),
default => null,
};
}
private function loadVideoContent(array $content): void
{
$this->videoFilename = $content['filename'] ?? '';
$this->videoTitle = $content['title'] ?? '';
$this->videoPosition = $content['position'] ?? 25;
$this->videoIsActive = true;
}
private function loadFooterContent(array $content): void
{
$this->footerHeadline = $content['headline'] ?? '';
$this->footerSubline = $content['subline'] ?? '';
$this->footerUrl = $content['url'] ?? '';
$this->footerIsActive = true;
}
private function loadMediaContent(array $content): void
{
$this->mediaType = $content['media_type'] ?? 'image';
$this->mediaCategory = $content['category'] ?? 'immobilien';
$this->mediaUrl = $content['media_url'] ?? '';
$this->mediaHeadline = $content['headline'] ?? '';
$this->mediaSubline = $content['subline'] ?? '';
$this->mediaDuration = $content['duration_seconds'] ?? 10;
$this->mediaIsActive = true;
}
private function loadSlideContent(array $content): void
{
$this->slideType = $content['type'] ?? 'product-hero';
$this->slideDuration = $content['duration'] ?? 8000;
$this->slideImageUrl = $content['image_url'] ?? '';
$this->slideBadge = $content['badge_text'] ?? '';
$this->slideEyebrow = $content['eyebrow'] ?? '';
$this->slideTitle = $content['title'] ?? '';
$this->slideSubline = $content['subline'] ?? '';
$this->slidePrice = $content['price'] ?? '';
$this->slideOriginalPrice = $content['original_price'] ?? '';
$this->slideTagText = $content['tag_text'] ?? '';
$this->slideBullets = $content['bullets'] ?? [];
$this->slideDisclaimer = $content['disclaimer'] ?? '';
$this->slideQrUrl = $content['qr_url'] ?? '';
$this->slideQrTitle = $content['qr_title'] ?? '';
$this->slideContact = $content['contact'] ?? '';
$this->slideShowBrandText = $content['show_brand_text'] ?? false;
$this->slideBrandTagline = $content['brand_tagline'] ?? '';
$this->slideIsActive = true;
}
/**
* @return array<string, mixed>
*/
private function buildItemContent(): array
{
return match ($this->itemType) {
'video' => [
'filename' => $this->videoFilename,
'title' => $this->videoTitle,
'position' => $this->videoPosition,
],
'footer' => [
'headline' => $this->footerHeadline,
'subline' => $this->footerSubline,
'url' => $this->footerUrl ?: null,
],
'media' => [
'media_type' => $this->mediaType,
'category' => $this->mediaCategory,
'media_url' => $this->mediaUrl,
'headline' => $this->mediaHeadline,
'subline' => $this->mediaSubline,
'duration_seconds' => $this->mediaDuration,
],
'slide' => [
'type' => $this->slideType,
'duration' => $this->slideDuration,
'image_url' => $this->slideImageUrl,
'badge_text' => $this->slideBadge,
'eyebrow' => $this->slideEyebrow,
'title' => $this->slideTitle,
'subline' => $this->slideSubline,
'price' => $this->slidePrice,
'original_price' => $this->slideOriginalPrice,
'tag_text' => $this->slideTagText,
'bullets' => $this->slideBullets,
'disclaimer' => $this->slideDisclaimer,
'qr_url' => $this->slideQrUrl,
'qr_title' => $this->slideQrTitle,
'contact' => $this->slideContact,
'show_brand_text' => $this->slideShowBrandText,
'brand_tagline' => $this->slideBrandTagline,
],
default => [],
};
}
private function getActiveFlag(): bool
{
return match ($this->itemType) {
'video' => $this->videoIsActive,
'footer' => $this->footerIsActive,
'media' => $this->mediaIsActive,
'slide' => $this->slideIsActive,
default => true,
};
}
private function defaultItemType(): string
{
return match ($this->version->type) {
DisplayVersionType::VideoDisplay => 'video',
DisplayVersionType::B2in => 'media',
DisplayVersionType::Offers => 'slide',
};
}
private function resetItemForm(): void
{
$this->itemId = null;
$this->itemType = '';
$this->videoFilename = '';
$this->videoTitle = '';
$this->videoPosition = 25;
$this->videoIsActive = true;
$this->footerHeadline = '';
$this->footerSubline = '';
$this->footerUrl = '';
$this->footerIsActive = true;
$this->mediaType = 'image';
$this->mediaCategory = 'immobilien';
$this->mediaUrl = '';
$this->mediaHeadline = '';
$this->mediaSubline = '';
$this->mediaDuration = 10;
$this->mediaIsActive = true;
$this->slideType = 'product-hero';
$this->slideDuration = 8000;
$this->slideImageUrl = '';
$this->slideBadge = '';
$this->slideEyebrow = '';
$this->slideTitle = '';
$this->slideSubline = '';
$this->slidePrice = '';
$this->slideOriginalPrice = '';
$this->slideTagText = '';
$this->slideBullets = [];
$this->slideDisclaimer = '';
$this->slideQrUrl = '';
$this->slideQrTitle = '';
$this->slideContact = '';
$this->slideShowBrandText = false;
$this->slideBrandTagline = '';
$this->slideIsActive = true;
}
public function render()
{
$items = $this->version->items()->get()->groupBy('item_type');
return view('livewire.admin.cms.display-version-editor', [
'items' => $items,
]);
}
}

View file

@ -0,0 +1,102 @@
<?php
namespace App\Livewire\Admin\Cms;
use App\Enums\DisplayVersionType;
use App\Models\DisplayVersion;
use Livewire\Component;
class DisplayVersionList extends Component
{
public $showCreateModal = false;
public $newName = '';
public $newType = '';
public function openCreateModal(): void
{
$this->newName = '';
$this->newType = '';
$this->showCreateModal = true;
}
public function createVersion(): void
{
$this->validate([
'newName' => 'required|string|max:255',
'newType' => 'required|string|in:video-display,b2in,offers',
], [
'newName.required' => 'Bitte geben Sie einen Namen ein.',
'newType.required' => 'Bitte wählen Sie einen Typ aus.',
]);
$version = DisplayVersion::create([
'name' => $this->newName,
'type' => $this->newType,
'settings' => $this->defaultSettingsForType($this->newType),
'is_active' => true,
]);
$this->showCreateModal = false;
$this->newName = '';
$this->newType = '';
session()->flash('success', 'Version "'.$version->name.'" wurde erstellt!');
$this->redirect(
route('admin.cms.display-version-edit', $version),
navigate: true
);
}
public function deleteVersion(int $id): void
{
$version = DisplayVersion::findOrFail($id);
$name = $version->name;
$version->delete();
session()->flash('success', 'Version "'.$name.'" wurde gelöscht!');
}
public function toggleActive(int $id): void
{
$version = DisplayVersion::findOrFail($id);
$version->update(['is_active' => ! $version->is_active]);
}
/**
* @return array<string, mixed>
*/
private function defaultSettingsForType(string $type): array
{
return match ($type) {
'b2in' => [
'theme' => 'dark',
'footer_name' => '',
'footer_url' => '',
'transition' => ['type' => 'crossfade', 'duration_ms' => 800],
'default_image_duration' => 10,
'rotation_weights' => ['immobilien' => 70, 'moebel' => 30],
'display_active' => true,
],
'offers' => [
'loop' => true,
'transition' => ['type' => 'fade', 'duration' => 600],
],
default => [],
};
}
public function render()
{
$versions = DisplayVersion::withCount(['items', 'displays'])
->orderBy('name')
->get();
return view('livewire.admin.cms.display-version-list', [
'versions' => $versions,
'types' => DisplayVersionType::cases(),
]);
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Livewire\Admin\Cms;
use FluxCms\Core\Services\MediaConversionService;
use Livewire\Component;
use Livewire\WithFileUploads;
class MediaLibraryUploader extends Component
{
use WithFileUploads;
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
public array $uploads = [];
public function updatedUploads(): void
{
$this->validate([
'uploads' => 'nullable|array|max:20',
'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
]);
$service = app(MediaConversionService::class);
foreach ($this->uploads as $file) {
$media = $service->storeUpload($file);
$this->dispatch('media-library-uploaded', mediaId: $media->id);
}
$this->uploads = [];
}
public function removeUpload(int $index): void
{
if (isset($this->uploads[$index])) {
unset($this->uploads[$index]);
$this->uploads = array_values($this->uploads);
}
}
public function render()
{
return view('livewire.admin.cms.media-library-uploader');
}
}

View file

@ -0,0 +1,139 @@
<?php
namespace App\Livewire\Admin\Cms;
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\MediaConversionService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\View\View;
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\WithPagination;
class MediaPicker extends Component
{
use WithFileUploads;
use WithPagination;
public ?int $value = null;
public string $field = 'media_id';
public string $type = 'image';
public string $profile = 'thumbnail';
public string $label = 'Bild auswählen';
public bool $showModal = false;
public string $search = '';
/** @var array<\Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
public array $quickUploads = [];
public function mount(?int $value = null): void
{
$this->value = $value;
}
public function openPicker(): void
{
$this->showModal = true;
}
public function selectMedia(int $id): void
{
$media = CmsMedia::find($id);
if (! $media) {
return;
}
if ($media->isImage() && $this->profile) {
$service = app(MediaConversionService::class);
if (! $media->hasConversion($this->profile)) {
$service->convert($media, $this->profile);
$media->refresh();
}
}
$this->value = $media->id;
$this->showModal = false;
$this->dispatch('media-selected', field: $this->field, mediaId: $media->id, url: $media->getConversionUrl($this->profile));
}
public function clearSelection(): void
{
$this->value = null;
$this->dispatch('media-selected', field: $this->field, mediaId: null, url: null);
}
public function updatedQuickUploads(): void
{
$this->validate([
'quickUploads' => 'nullable|array|max:5',
'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
]);
$service = app(MediaConversionService::class);
$lastMedia = null;
foreach ($this->quickUploads as $file) {
$lastMedia = $service->storeUpload($file);
if ($lastMedia->isImage() && $this->profile) {
$service->convert($lastMedia, $this->profile);
$lastMedia->refresh();
}
}
$this->quickUploads = [];
if ($lastMedia) {
$this->value = $lastMedia->id;
$this->showModal = false;
$this->dispatch('media-selected', field: $this->field, mediaId: $lastMedia->id, url: $lastMedia->getConversionUrl($this->profile));
}
}
public function removeQuickUpload(int $index): void
{
if (isset($this->quickUploads[$index])) {
unset($this->quickUploads[$index]);
$this->quickUploads = array_values($this->quickUploads);
}
}
public function render(): View
{
return view('livewire.admin.cms.media-picker', [
'selectedMedia' => $this->resolveSelectedMedia(),
'mediaItems' => $this->resolveMediaItems(),
]);
}
private function resolveSelectedMedia(): ?CmsMedia
{
if (! $this->value) {
return null;
}
return CmsMedia::find($this->value);
}
/**
* @return LengthAwarePaginator<int, CmsMedia>
*/
private function resolveMediaItems(): LengthAwarePaginator
{
return CmsMedia::query()
->when($this->type === 'image', fn ($q) => $q->images())
->when($this->type === 'pdf', fn ($q) => $q->pdfs())
->when($this->type === 'document', fn ($q) => $q->documents())
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%"))
->orderByDesc('created_at')
->paginate(18);
}
}

524
dev/flux-cms/PLAN.md Normal file
View file

@ -0,0 +1,524 @@
# Flux CMS Integration Entwicklungsplan
> **Ziel:** Integration des flux-cms Packages für die b2in-Webseite.
> **Scope:** Nur b2in weitere Subseiten (b2a, stileigentum, style2own) folgen später.
> **Stand:** 2026-03-18
---
## Ausgangslage
### Was existiert
- **Package:** `packages/flux-cms/` (core, components, starter-components) liegt im Projekt
- **Composer:** `packages/*/*` Repository-Pfad ist in `composer.json` konfiguriert
- **Frontend:** Vollständige b2in-Webseite mit ~15 Seiten
- **Content-Quelle:** `config/content.php` statische PHP-Arrays pro Theme
- **Content-Zugriff:** Livewire-Sections laden via `config("content.themes.{$theme}.{$section}")`
- **Immobilien/Projekte:** Komplett in `config/content.php` definiert (Slug, Titel, Preise, Galerie, Quick Facts, Investment Case, Location etc.), Route `immobilien/{slug}` liest direkt aus Config
- **Admin-Portal:** `portal.b2in.test` mit eigenem CMS (Cabinets/Displays), User-/Partner-Management
- **Domain-System:** Multi-Domain-Setup in `config/domains.php`
### Was fehlt
- flux-cms/core ist **nicht** als Composer-Dependency installiert
- Keine `app/helpers.php` mit `cms()`/`tcms()`/`cms_media_url()`
- Keine `config/flux-cms.php` publiziert
- Keine `flux_cms_*` Datenbanktabellen
- Keine Admin-Views/Routes für das CMS
- Keine Medienbibliothek
- Kein Immobilien-Model (Projekte leben in Config)
### Scope-Einschränkung Phase 1
**Nur diese Tabellen werden initial benötigt:**
- `flux_cms_contents` Alle Seiteninhalte (Key-Value mit Übersetzungen)
- `flux_cms_media` Medienbibliothek (Bilder, PDFs)
**Nicht benötigt (kommt später bei Bedarf):**
- ~~`flux_cms_news_items`~~ Nachrichteneinträge
- ~~`flux_cms_industries`~~ Branchen
- ~~`flux_cms_faqs`~~ FAQ-Einträge
- ~~`flux_cms_downloads`~~ Downloads
- ~~`flux_cms_linkedin_posts`~~ LinkedIn-Posts
- ~~`flux_cms_search_index`~~ Suchindex
**Zusätzlich benötigt:**
- Neues **Immobilien/Projekte-Model** mit eigenem CRUD im CMS-Backend (ersetzt die statischen Projekte aus `config/content.php`)
---
## Phasen-Übersicht
| Phase | Beschreibung | Abhängig von |
|-------|-------------|--------------|
| **1** | Package-Installation & Infrastruktur | |
| **2** | Immobilien-Model & CRUD | Phase 1 |
| **3** | Content-Migration (config → DB) | Phase 1 |
| **4** | CMS Admin-Backend | Phase 1 |
| **5** | Frontend-Umstellung (config → cms) | Phase 2, 3 |
| **6** | Medienbibliothek & Bilder | Phase 1, 4 |
| **7** | Tests | Phase 26 |
| **8** | Feinschliff & Dokumentation | Phase 57 |
---
## Phase 1 Package-Installation & Infrastruktur
### 1.1 Composer-Dependency installieren
```bash
composer require flux-cms/core:@dev
composer require intervention/image
```
**Prüfen:**
- [ ] `FluxCmsServiceProvider` wird automatisch geladen
- [ ] Keine Konflikte mit bestehenden Dependencies
### 1.2 Konfiguration publizieren
```bash
php artisan vendor:publish --tag=flux-cms-config
```
**Anpassen in `config/flux-cms.php`:**
- `default_locale``'de'`
- `locales``['de' => 'Deutsch', 'en' => 'English']`
- `media.profiles` → an b2in-Bildgrößen anpassen
- `routes.enabled``false` (eigene Admin-Routes im Portal)
### 1.3 Migrations ausführen
**Nur die benötigten Migrations:**
- `create_flux_cms_contents_table`
- `create_flux_cms_media_table`
Die restlichen Migrations (news, industries, faqs, downloads, linkedin, search_index) werden **nicht ausgeführt** sie liegen im Package und können bei Bedarf später migriert werden.
**Optionen:**
- A) Alle Migrations laufen lassen (Tabellen existieren, werden aber nicht genutzt) einfacher
- B) Nur selektiv migrieren sauberer, erfordert ggf. Anpassung am ServiceProvider
**Empfehlung: Option A** leere Tabellen stören nicht und vereinfachen spätere Erweiterung.
```bash
php artisan migrate
```
### 1.4 Helper-Funktionen einrichten
**Erstellen:** `app/helpers.php` mit `cms()`, `tcms()`, `cms_media_url()`, `media_url()`
**Registrieren in `composer.json`:**
```json
"autoload": {
"files": ["app/helpers.php"]
}
```
```bash
composer dump-autoload
```
### 1.5 Storage-Link
```bash
php artisan storage:link
```
**Ergebnis Phase 1:** Package installiert, DB-Tabellen vorhanden, Helper verfügbar.
---
## Phase 2 Immobilien/Projekte-Model & CRUD
### 2.1 Datenstruktur analysieren
Aktuelle Projekt-Daten aus `config/content.php` (am Beispiel Azizi Creek Views 4):
| Feld | Typ | Beispiel |
|------|-----|---------|
| `slug` | string | `azizi-creek-views-4` |
| `title` | string | `Azizi Developments: Creek Views 4` |
| `location` | string | `Al Jaddaf, Dubai` |
| `status` | string | `NEW LAUNCH` |
| `launch_date` | string | `03.03.2026` |
| `price_from` | integer (AED) | `1125000` |
| `image` | string (Pfad) | `expose/a1/image-4.jpeg` |
| `highlights` | array\<string\> | `['Prime Waterfront Views', ...]` |
| `quick_facts` | array\<{icon, label, value}\> | Typen, Größe, Einheiten, Entwickler |
| `investment_case` | object | `{title, text, views[]}` |
| `gallery` | array\<string\> | Bildpfade |
| `location_info` | object | `{title, map_url, points[]}` |
| `contact` | object | `{title, subtitle, options[]}` |
### 2.2 Migration erstellen
Neue Migration `create_cms_projects_table`:
```php
Schema::create('cms_projects', function (Blueprint $table) {
$table->id();
$table->string('slug')->unique();
$table->json('title'); // translatable
$table->json('location'); // translatable
$table->string('status')->nullable();
$table->date('launch_date')->nullable();
$table->unsignedInteger('price_from_aed')->nullable();
$table->string('currency')->default('AED');
$table->string('image')->nullable(); // CmsMedia Referenz
$table->json('highlights')->nullable(); // translatable array
$table->json('quick_facts')->nullable(); // [{icon, label, value}]
$table->json('investment_case')->nullable(); // {title, text, views[]}
$table->json('gallery')->nullable(); // [filename, ...]
$table->json('location_info')->nullable(); // {title, map_url, points[]}
$table->json('contact')->nullable(); // {title, subtitle, options[]}
$table->boolean('is_published')->default(false);
$table->unsignedInteger('order')->default(0);
$table->timestamps();
});
```
### 2.3 Model erstellen
`App\Models\CmsProject` mit:
- `HasTranslations` (Spatie) für `title`, `location`, `highlights`
- Scopes: `published()`, `ordered()`
- `toFrontendArray()` → kompatibles Array für bestehende Blade-Views
- `getFormattedPrice()` → nutzt `PriceHelper::formatAed()`
- Accessors für `gallery_urls`, `image_url` etc.
### 2.4 Factory & Seeder
- **Factory:** `CmsProjectFactory` für Tests
- **Seeder:** `CmsProjectSeeder` importiert die bestehenden Projekte aus `config/content.php` in die DB
### 2.5 Admin CRUD (Volt-Komponente)
Admin-View `admin.cms.projects-index`:
- Liste aller Projekte (Titel, Status, Preis, Published, Order)
- Erstellen/Bearbeiten Modal oder Inline
- Felder: alle aus 2.2
- Galerie-Management via MediaPicker
- Bild-Upload via MediaLibraryUploader
- Sortierung per Drag & Drop oder Order-Feld
- Publish/Unpublish Toggle
### 2.6 Route für Immobilien-Show anpassen
```php
// Vorher:
Route::get('/immobilien/{slug}', function (string $slug) {
$project = config("content.themes.{$theme}.immobilien_projects.projects.{$slug}");
...
});
// Nachher:
Route::get('/immobilien/{slug}', function (string $slug) {
$project = CmsProject::where('slug', $slug)->published()->firstOrFail();
return view('web.immobilien-show', ['project' => $project->toFrontendArray()]);
});
```
**Ergebnis Phase 2:** Immobilien-Projekte in DB, editierbar im CMS, Frontend nutzt Model.
---
## Phase 3 Content-Migration (config → DB)
### 3.1 Content-Struktur analysieren
Die `config/content.php` enthält das b2in-Theme mit folgenden Sektionen:
| Page | Section | Typ |
|------|---------|-----|
| global | `announcement_bar` | Text + Links |
| global | `header` | Navigation |
| global | `footer` | Text + Links |
| home | `hero` | Text + Bild + Stats |
| home | `founder_bar` | Text + Bild |
| home | `synergie_section` | Text + Bild |
| home | `vision_section` | Text + Bild |
| home | `ecosystem_core` | Text + Items |
| home | `cta_section` | Text + Link |
| home | `brand_worlds` | Text + Items |
| about | `about_hero`, `our_story`, `our_values`, `leadership_team` | Diverse |
| immobilien | `immobilien_hero_v2`, `immobilien_warum_dubai`, `immobilien_kaufprozess`, `immobilien_bruecke`, `immobilien_mindset`, `immobilien_moebel_vorteil` | Diverse |
| faq | FAQ-Kategorien | Q&A |
| netzwerk | Hero, Stats, Sections | Diverse |
| contact | Form-Info | Text |
| impressum/privacy/terms/cookie-policy | Langtext | HTML |
### 3.2 CmsContentSeeder erstellen
**Strategie:** Alle b2in-Inhalte aus `config/content.php` in `flux_cms_contents` überführen.
- **Key-Schema:** `{page}.{section}.{field}` (z.B. `home.hero.title`, `about.our_story.title`)
- **Typen:** `text`, `html`, `image`, `json` (für Arrays wie `pillars`, `stats`, `navigation`)
- **Gruppen:** `home`, `about`, `immobilien`, `netzwerk`, `faq`, `contact`, `impressum`, `privacy`, `terms`, `cookie_policy`, `global` (Header, Footer)
**Datei:** `database/seeders/CmsContentSeeder.php` liest `config/content.php` und schreibt in DB.
### 3.3 Seeders ausführen & verifizieren
```bash
php artisan db:seed --class=CmsContentSeeder
```
**Ergebnis Phase 3:** Alle b2in-Inhalte in der DB, abrufbar über `cms('home.hero.title')`.
---
## Phase 4 CMS Admin-Backend
### 4.1 CMS als eigener Menüpunkt
Das CMS wird im Admin-Portal (`portal.b2in.test`) als **eigener Top-Level-Menüpunkt "CMS"** in der Sidebar integriert.
**Sidebar-Erweiterung** (`resources/views/components/layouts/app/sidebar.blade.php`):
```
CMS
├── Dashboard (Übersicht: Anzahl Contents, Medien, Projekte)
├── Inhalte (Content-Editor für Text/HTML/Image/JSON Keys)
├── Projekte (Immobilien-CRUD aus Phase 2)
├── Medienbibliothek (Upload, Grid, Conversions)
```
### 4.2 CMS-Layout
**Entscheidung:** Die CMS-Views nutzen das bestehende Admin-Portal-Layout (`admin-master.blade.php` / `app.blade.php`), damit Navigation und User-Menü konsistent bleiben.
→ Das Reference-Layout `layout-cms.blade.php` wird **nicht** als separates Layout genutzt, sondern als Vorlage für die Content-Struktur innerhalb des bestehenden Layouts.
### 4.3 Admin-Views erstellen
Aus den Package-Referenz-Views nur die benötigten kopieren und anpassen:
| View | Quelle | Ziel |
|------|--------|------|
| Dashboard | `admin-reference/cms/dashboard.blade.php` | `livewire/admin/cms/dashboard.blade.php` |
| Content-Editor | `admin-reference/cms/content-index.blade.php` | `livewire/admin/cms/content-index.blade.php` |
| Medienbibliothek | `admin-reference/cms/media-index.blade.php` | `livewire/admin/cms/media-index.blade.php` |
| MediaPicker | `admin-reference/cms/media-picker.blade.php` | `livewire/admin/cms/media-picker.blade.php` |
| MediaUploader | `admin-reference/cms/media-library-uploader.blade.php` | `livewire/admin/cms/media-library-uploader.blade.php` |
| **Projekte** | **Neu erstellen** | `livewire/admin/cms/projects-index.blade.php` |
**Nicht benötigt (vorerst):** news-index, industries-index, faqs-index, linkedin-index, downloads-index, team-index, search-index
### 4.4 Livewire-Komponenten einrichten
```bash
mkdir -p app/Livewire/Admin/Cms/
```
Aus Package kopieren + Namespace anpassen:
- `MediaLibraryUploader.php``App\Livewire\Admin\Cms`
- `MediaPicker.php``App\Livewire\Admin\Cms`
- `MediaUploader.php``App\Livewire\Admin\Cms`
### 4.5 Admin-Routes registrieren
In `routes/admin.php` innerhalb der bestehenden `auth`-Middleware-Gruppe:
```php
// Flux CMS Routes
Volt::route('admin/cms', 'admin.cms.dashboard')->name('cms.dashboard');
Volt::route('admin/cms/content', 'admin.cms.content-index')->name('cms.content.index');
Volt::route('admin/cms/media', 'admin.cms.media-index')->name('cms.media.index');
Volt::route('admin/cms/projects', 'admin.cms.projects-index')->name('cms.projects.index');
```
**Ergebnis Phase 4:** Funktionierendes Admin-Backend unter `portal.b2in.test/admin/cms` mit Dashboard, Content-Editor, Medienbibliothek und Projekte-CRUD.
---
## Phase 5 Frontend-Umstellung (config → cms)
### 5.1 Strategie: Dualer Betrieb mit Fallback
Die Umstellung erfolgt inkrementell. Jede Section einzeln umstellen, mit Fallback auf `config()`:
```php
// Vorher (Hero.php):
$this->content = config("content.themes.{$theme}.hero", []);
// Nachher:
$this->content = $this->loadFromCms('home.hero');
// Fallback-Methode in einem Trait oder Base-Class:
protected function loadFromCms(string $group): array
{
$cmsContent = app(CmsContentService::class)->getGroup($group);
if (empty($cmsContent)) {
$theme = config('app.theme', 'b2in');
$section = Str::afterLast($group, '.');
return config("content.themes.{$theme}.{$section}", []);
}
return $cmsContent;
}
```
### 5.2 Sections schrittweise umstellen
**Reihenfolge (nach Priorität / Sichtbarkeit):**
1. **Home-Page Sections:**
- [ ] Hero → `home.hero`
- [ ] FounderBar → `home.founder_bar`
- [ ] ContentSection → `home.{section}` (dynamisch)
- [ ] VisionSection → `home.vision_section`
- [ ] EcosystemCore → `home.ecosystem_core`
- [ ] CTASection → `home.cta_section`
2. **Globale Elemente:**
- [ ] Header (Navigation) → `global.header`
- [ ] Footer → `global.footer`
- [ ] AnnouncementBar → `global.announcement_bar`
3. **Immobilien-Seite (statischer Content):**
- [ ] HeroV2 → `immobilien.hero_v2`
- [ ] WarumDubai → `immobilien.warum_dubai`
- [ ] Kaufprozess → `immobilien.kaufprozess`
- [ ] Brücke → `immobilien.bruecke`
- [ ] Mindset → `immobilien.mindset`
- [ ] MöbelVorteil → `immobilien.moebel_vorteil`
- [ ] Projekte-Liste → **CmsProject::published()->ordered()** (aus Phase 2)
4. **Unterseiten:**
- [ ] About
- [ ] FAQ
- [ ] Contact
- [ ] Impressum / Privacy / Terms / Cookie-Policy
- [ ] Netzwerk
- [ ] Service / Portfolio
### 5.3 Immobilien-Route umstellen
```php
// routes/web.php von config auf Model:
Route::get('/immobilien/{slug}', function (string $slug) {
$project = \App\Models\CmsProject::where('slug', $slug)
->published()
->firstOrFail();
return view('web.immobilien-show', ['project' => $project->toFrontendArray()]);
})->name('immobilien.show');
```
Die `immobilien.blade.php` Projekte-Liste ebenfalls umstellen:
```php
// Vorher: $projects = config("content.themes.{$theme}.immobilien_projects", []);
// Nachher: $projects = CmsProject::published()->ordered()->get();
```
### 5.4 Bilder umstellen
```diff
- asset('img/assets/' . $heroV2['image'])
+ cms_media_url('immobilien.hero_v2.image', 'hero')
```
**Ergebnis Phase 5:** b2in-Frontend liest Inhalte aus DB + CmsProject-Model, editierbar über Admin.
---
## Phase 6 Medienbibliothek & Bilder
### 6.1 Bestehende Bilder importieren
Alle b2in-Bilder (aus `public/img/assets/`) in die CMS-Medienbibliothek importieren:
- `b2in/` allgemeine b2in-Bilder (Hero, Founder, Sections)
- `expose/` Immobilien-Projektbilder
### 6.2 CmsMediaSeeder
Seeder erstellt `CmsMedia`-Einträge für alle bestehenden Bilder und verknüpft sie mit den CmsContent-Keys vom Typ `image`.
### 6.3 Bildprofile definieren
In `config/flux-cms.php`:
- `hero` → 1920×800 (Hero-Banner)
- `card` → 768×512 (Kacheln, Sections)
- `thumbnail` → 400×300 (Listen, Übersichten)
- `avatar` → 400×400 (Team-Fotos, Founder)
- `gallery` → 1200×900 (Projekt-Galerie)
- `og_image` → 1200×630 (Social Sharing)
### 6.4 Conversions generieren
```bash
php artisan flux-cms:clear-cache
```
**Ergebnis Phase 6:** Alle Bilder in Medienbibliothek, Conversions generiert, URLs aufgelöst.
---
## Phase 7 Tests
### 7.1 Referenz-Tests kopieren (selektiv)
```bash
mkdir -p tests/Feature/Cms
cp packages/flux-cms/core/tests-reference/Feature/Cms/CmsMediaTest.php tests/Feature/Cms/
cp packages/flux-cms/core/tests-reference/Feature/Cms/CmsContentServiceTest.php tests/Feature/Cms/
```
### 7.2 Projektspezifische Tests
- **CmsProjectTest** CRUD, published/unpublished, toFrontendArray(), Validierung
- **CmsContentSeederTest** Prüft ob alle Keys aus config in DB existieren
- **CmsAdminAccessTest** Prüft Zugriffskontrolle auf CMS-Admin-Routen
- **ImmobilienRouteTest** Prüft `/immobilien/{slug}` mit DB-Daten
- **FrontendFallbackTest** Prüft dualen Betrieb (CMS → config Fallback)
- **MediaIntegrationTest** Upload, Conversion, URL-Auflösung
### 7.3 Tests ausführen
```bash
php artisan test --compact --filter=Cms
php artisan test --compact --filter=Immobilien
```
**Ergebnis Phase 7:** Alle CMS-Funktionalitäten sind getestet.
---
## Phase 8 Feinschliff & Dokumentation
### 8.1 Cache & Performance
- CMS-Content-Cache aktivieren
- Eager Loading optimieren
- Prüfen: Keine N+1-Queries auf Frontseiten
### 8.2 config/content.php aufräumen
- Nach vollständiger Umstellung: b2in-Theme-Daten als `@deprecated` markieren
- Noch nicht entfernen (Fallback für andere Themes!)
- Immobilien-Projekte aus Config entfernen (leben jetzt in DB)
### 8.3 Sidebar-Berechtigungen
- CMS-Zugang über Spatie-Permission absichern (`permission:manage-cms`)
- CMS-Menüpunkt nur für berechtigte User anzeigen
### 8.4 Dokumentation
- Diesen Plan aktualisieren mit Status
- Notizen für Integration weiterer Subseiten (b2a, stileigentum, style2own)
---
## Offene Fragen / Entscheidungen
| # | Frage | Entscheidung |
|---|-------|-------------|
| 1 | Alle flux-cms Migrations laufen lassen oder nur contents + media? | Empfehlung: Alle (leere Tabellen stören nicht) |
| 2 | CmsProject: Eigene Migration oder flux-cms erweitern? | Eigene Migration im Projekt |
| 3 | FAQ-Daten: Vorerst in config belassen oder direkt in `flux_cms_faqs`? | Vorerst config |
| 4 | Magazin: Eigenes System belassen oder später auf CMS? | Vorerst belassen |
| 5 | Andere Themes (b2a etc.) weiterhin via `config()`? | Ja |
---
## Fortschritt
| Phase | Status | Notizen |
|-------|--------|---------|
| 1 Installation & Infrastruktur | ✅ Fertig | 2026-03-18: Package installiert, Config angepasst, Migrations gelaufen, Helpers eingerichtet, 434 Tests grün |
| 2 Immobilien-Model & CRUD | ✅ Fertig | 2026-03-18: Migration, Model, Factory, Seeder, 9 Tests grün. CRUD Admin-View folgt in Phase 4. |
| 3 Content-Migration | ✅ Fertig | 2026-03-18: 61 Sections in 8 Gruppen migriert, Section-als-JSON-Ansatz, 8 Tests grün |
| 4 CMS Admin-Backend | ✅ Fertig | 2026-03-18: Dashboard, Content-Editor, Projekte-CRUD, Medienbibliothek, Sidebar-Menü, MediaPicker/Uploader, 16 Tests grün |
| 5 Frontend-Umstellung | ⬜ Offen | |
| 6 Medienbibliothek | ⬜ Offen | |
| 7 Tests | ⬜ Offen | |j
| 8 Feinschliff | ⬜ Offen | |

106
dev/flux-cms/helpers.php Normal file
View file

@ -0,0 +1,106 @@
<?php
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\CmsContentService;
if (! function_exists('legal_page')) {
/**
* Rechtstexte: zuerst CMS (Gruppe „legal“), sonst Lang-Datei b2in_legal.
*
* @return array<string, mixed>
*/
function legal_page(string $key): array
{
$fromCms = app(CmsContentService::class)->get('legal.'.$key);
if (is_array($fromCms) && isset($fromCms['content'])) {
return $fromCms;
}
return trans('b2in_legal.'.$key);
}
}
if (! function_exists('cms')) {
/**
* Resolve a CMS content value by dot-notation key.
*
* First segment is the group, rest is the key: "home.hero.title"
* Falls back to __() if no DB entry exists.
*
* @param array<string, string> $replace
*/
function cms(string $key, array $replace = [], ?string $locale = null): mixed
{
return app(CmsContentService::class)->get($key, $replace, $locale);
}
}
if (! function_exists('tcms')) {
/**
* Typed CMS always returns a string.
*
* @param array<string, string> $replace
*/
function tcms(string $key, array $replace = [], ?string $locale = null): string
{
$text = cms($key, $replace, $locale);
return is_string($text) ? $text : (string) $text;
}
}
if (! function_exists('cms_media_url')) {
/**
* Resolve a CMS content key (type=image) to a full media URL.
* Falls back to asset('assets/images/...') if not found in media library.
*/
function cms_media_url(string $key, string $profile = ''): string
{
$filename = cms($key);
if (! $filename || ! is_string($filename) || $filename === $key) {
$fallback = str_replace('.', '/', $key);
return asset('assets/images/'.basename($fallback));
}
return media_url($filename, $profile);
}
}
if (! function_exists('media_url')) {
/**
* Resolve a CmsMedia filename to its full storage URL.
* Uses in-memory cache to avoid repeated DB queries.
*/
function media_url(?string $filename, string $profile = ''): string
{
if (! $filename || $filename === '') {
return '';
}
static $resolved = [];
$cacheKey = $filename.'|'.$profile;
if (isset($resolved[$cacheKey])) {
return $resolved[$cacheKey];
}
$media = CmsMedia::where('filename', $filename)->first();
if (! $media) {
$resolved[$cacheKey] = asset('assets/images/'.$filename);
return $resolved[$cacheKey];
}
if ($profile && $media->hasConversion($profile)) {
$resolved[$cacheKey] = $media->getConversionUrl($profile);
} else {
$resolved[$cacheKey] = $media->getUrl();
}
return $resolved[$cacheKey];
}
}

15
dev/flux-cms/tasks.md Normal file
View file

@ -0,0 +1,15 @@
### Aufgabe
Integration des selbst entwickelten Packages flux-cms
packages/flux-cms
Das Package wurde entwickelt, um in einem stehenden laravel framework mit livewire / (Volt) / Fluxi Eingesetzt zu werden.
Der derzeitige Stand ist aus einem anderen Projekt, wo es spezifische Aufgaben schon sehr gut erledigt und genau das tut was es soll.
In diesem Fall geht es darum im ersten Punkt die aktuelle b2in Webseite in das System zu integrieren.
Wichtig die weiteren Subseite Müssen später folgen. Hier geht es jetzt primär erst mal um den aktuellen Stand, der auch online ist.
Nutze diesen Ordner für den Prozess der Integration und dokumentiere ihn hier, so dass die Arbeit jederzeit wieder aufgenommen werden kann.
Erstelle einen Entwicklungsplan, der dann Stück Stück abgearbeitet werden kann, um eine saubere Integration zu gewährleisten