10-04-2026
This commit is contained in:
parent
4d6b4930b2
commit
4bb89aad8c
836 changed files with 52961 additions and 5950 deletions
45
dev/flux-cms/Admin/Cms/MediaLibraryUploader.php
Normal file
45
dev/flux-cms/Admin/Cms/MediaLibraryUploader.php
Normal 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');
|
||||
}
|
||||
}
|
||||
139
dev/flux-cms/Admin/Cms/MediaPicker.php
Normal file
139
dev/flux-cms/Admin/Cms/MediaPicker.php
Normal 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);
|
||||
}
|
||||
}
|
||||
39
dev/flux-cms/Admin/Cms/MediaUploader.php
Normal file
39
dev/flux-cms/Admin/Cms/MediaUploader.php
Normal 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');
|
||||
}
|
||||
}
|
||||
302
dev/flux-cms/Cms/CabinetDisplay.php
Normal file
302
dev/flux-cms/Cms/CabinetDisplay.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
189
dev/flux-cms/Cms/CabinetInfoTablet.php
Normal file
189
dev/flux-cms/Cms/CabinetInfoTablet.php
Normal 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');
|
||||
}
|
||||
}
|
||||
153
dev/flux-cms/Cms/DisplayList.php
Normal file
153
dev/flux-cms/Cms/DisplayList.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
437
dev/flux-cms/Cms/DisplayVersionEditor.php
Normal file
437
dev/flux-cms/Cms/DisplayVersionEditor.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
102
dev/flux-cms/Cms/DisplayVersionList.php
Normal file
102
dev/flux-cms/Cms/DisplayVersionList.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
45
dev/flux-cms/Cms/MediaLibraryUploader.php
Normal file
45
dev/flux-cms/Cms/MediaLibraryUploader.php
Normal 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');
|
||||
}
|
||||
}
|
||||
139
dev/flux-cms/Cms/MediaPicker.php
Normal file
139
dev/flux-cms/Cms/MediaPicker.php
Normal 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
524
dev/flux-cms/PLAN.md
Normal 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 2–6 |
|
||||
| **8** | Feinschliff & Dokumentation | Phase 5–7 |
|
||||
|
||||
---
|
||||
|
||||
## 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
106
dev/flux-cms/helpers.php
Normal 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
15
dev/flux-cms/tasks.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue