b2in/app/Livewire/Admin/Cms/DisplayVersionEditor.php
Kevin Adametz 6c6d683b9a Display CMS Optimierungen 29-05-2026
- Mediathek: Video-Vorschaubilder statt Icons (FFmpeg-Thumbnails + Backfill-Command), Kategorie "Sonstiges"
- B2in Media-Picker zeigt alle Medientypen, Typ wird automatisch erkannt; Thumbnail-Preview vor allen Medien-URL-Feldern
- B2in Marke/Footer: Footer ein/aus, Logo+Claim frei positionierbar (Ecken) mit Constraints, separate Anzeige-Schalter
- Angebote-Modul dynamisch: kein Slide-Typ mehr, einheitliches Detail-Layout mit ein-/ausblendbaren Bloecken, Logo/Brand pro Slide, Streichpreis-Option
- Player: leere Module stoppen Endlosschleife, dynamische Layout-Anpassung bei verstecktem Footer/Header
- Fix: Script-Ladereihenfolge (Livewire vor Flux), entfernte stale public/flux/flux.js, Modal-Crash beim Aktualisieren behoben

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:57:33 +00:00

609 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Livewire\Admin\Cms;
use App\Enums\DisplayVersionType;
use App\Models\DisplayMedia;
use App\Models\DisplayVersion;
use App\Models\DisplayVersionItem;
use App\Support\DisplayModuleSettings;
use Livewire\Attributes\On;
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 (single dynamic detail layout)
public bool $slideShowLogo = true;
public string $slideLogoUrl = '';
public string $slideBrandText = '';
public int $slideDuration = 8000;
public string $slideImageUrl = '';
public string $slideBadge = '';
public bool $slideShowBadge = true;
public string $slideEyebrow = '';
public bool $slideShowEyebrow = true;
public string $slideTitle = '';
public string $slideSubline = '';
public bool $slideShowSubline = false;
public string $slidePrice = '';
public string $slideOriginalPrice = '';
public bool $slideStrikeOriginalPrice = false;
public string $slideTagText = '';
public bool $slideShowPrice = false;
/** @var array<string> */
public array $slideBullets = [];
public bool $slideShowBullets = true;
public string $slideDisclaimer = '';
public bool $slideShowDisclaimer = false;
public string $slideQrUrl = '';
public string $slideQrTitle = '';
public bool $slideShowQr = true;
public string $slideContact = '';
public bool $slideShowContact = true;
public string $slideBrandTagline = '';
public bool $slideIsActive = true;
// Settings Modal
public bool $showSettingsModal = false;
public array $settings = [];
public int $previewFrameRefreshCounter = 0;
/** @var array<string> */
public const BRAND_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
public function mount(DisplayVersion $displayVersion): void
{
$this->version = $displayVersion;
$this->versionName = $displayVersion->name;
$this->settings = $this->settingsWithDefaults();
$this->normalizeBrandPositions();
}
public function updated(string $name): void
{
if (str_starts_with($name, 'settings.show_footer')
|| str_starts_with($name, 'settings.logo_position')
|| str_starts_with($name, 'settings.claim_position')) {
$this->normalizeBrandPositions();
}
}
public function toggleTheme(): void
{
$settings = $this->settingsWithDefaults();
$settings['theme'] = ($settings['theme'] ?? 'dark') === 'dark' ? 'light' : 'dark';
$this->version->update(['settings' => $settings]);
$this->settings = $settings;
$this->refreshModulePreview();
}
public function saveName(): void
{
$this->validate([
'versionName' => 'required|string|max:255',
]);
$this->version->update(['name' => $this->versionName]);
$this->refreshModulePreview();
session()->flash('success', 'Name aktualisiert!');
}
// ========================================
// SETTINGS
// ========================================
public function openSettingsModal(): void
{
$this->settings = $this->settingsWithDefaults();
$this->showSettingsModal = true;
}
public function saveSettings(): void
{
$this->normalizeBrandPositions();
$this->version->update(['settings' => $this->settings]);
$this->showSettingsModal = false;
$this->refreshModulePreview();
session()->flash('success', 'Einstellungen gespeichert!');
}
/**
* Keep the B2in logo/claim corners consistent:
* - bottom corners are only valid while the footer is hidden,
* - the claim can never sit in the same corner as the logo.
*/
private function normalizeBrandPositions(): void
{
if ($this->version->type !== DisplayVersionType::B2in) {
return;
}
$footerShown = ($this->settings['show_footer'] ?? true) !== false;
$allowed = $footerShown ? ['top-left', 'top-right'] : self::BRAND_POSITIONS;
$logo = $this->settings['logo_position'] ?? 'top-left';
$claim = $this->settings['claim_position'] ?? 'top-right';
$logo = $this->moveIntoAllowed($logo, $allowed);
$claim = $this->moveIntoAllowed($claim, $allowed);
if ($claim === $logo) {
$claim = collect($allowed)->first(fn (string $position) => $position !== $logo) ?? $claim;
}
$this->settings['logo_position'] = $logo;
$this->settings['claim_position'] = $claim;
}
/**
* @param array<string> $allowed
*/
private function moveIntoAllowed(string $position, array $allowed): string
{
if (in_array($position, $allowed, true)) {
return $position;
}
// Pull bottom corners up to the matching top corner when forbidden.
$fallback = str_replace('bottom-', 'top-', $position);
return in_array($fallback, $allowed, true) ? $fallback : ($allowed[0] ?? 'top-left');
}
// ========================================
// 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;
$item = 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->itemId = $item->id;
$this->itemType = $item->item_type;
$this->loadItemContent($item->fresh());
$this->showItemModal = true;
$this->refreshModulePreview();
}
public function deleteItem(int $id): void
{
DisplayVersionItem::findOrFail($id)->delete();
$this->refreshModulePreview();
session()->flash('success', 'Inhalt gelöscht!');
}
public function toggleItemStatus(int $id): void
{
$item = DisplayVersionItem::findOrFail($id);
$item->update(['is_active' => ! $item->is_active]);
$this->refreshModulePreview();
}
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]);
$this->refreshModulePreview();
}
}
public function modulePreviewUrl(): string
{
return url('/preview/module/'.$this->version->id).'?refresh='.$this->previewFrameRefreshCounter;
}
public function itemPreviewUrl(): string
{
if (! $this->itemId) {
return $this->modulePreviewUrl();
}
return url('/preview/module/'.$this->version->id.'/item/'.$this->itemId).'?refresh='.$this->previewFrameRefreshCounter;
}
#[On('display-media-selected')]
public function onDisplayMediaSelected(string $field, ?int $mediaId, ?string $url): void
{
if (! $url) {
return;
}
match ($field) {
'videoFilename' => $this->videoFilename = $url,
'mediaUrl' => $this->mediaUrl = $url,
'slideImageUrl' => $this->slideImageUrl = $url,
'slideLogoUrl' => $this->slideLogoUrl = $url,
'settings.header_logo_url' => $this->settings['header_logo_url'] = $url,
default => null,
};
// The media type for a B2in playlist item is derived from the chosen
// media the type selector in the form is only an informational hint.
if ($field === 'mediaUrl' && $mediaId) {
$media = DisplayMedia::find($mediaId);
if ($media) {
$this->mediaType = $media->isVideo() ? 'video' : 'image';
}
}
}
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;
$isActive = (bool) $item->is_active;
match ($item->item_type) {
'video' => $this->loadVideoContent($content, $isActive),
'footer' => $this->loadFooterContent($content, $isActive),
'media' => $this->loadMediaContent($content, $isActive),
'slide' => $this->loadSlideContent($content, $isActive),
default => null,
};
}
/**
* @param array<string, mixed> $content
*/
private function loadVideoContent(array $content, bool $isActive): void
{
$this->videoFilename = $content['filename'] ?? '';
$this->videoTitle = $content['title'] ?? '';
$this->videoPosition = $content['position'] ?? 25;
$this->videoIsActive = $isActive;
}
/**
* @param array<string, mixed> $content
*/
private function loadFooterContent(array $content, bool $isActive): void
{
$this->footerHeadline = $content['headline'] ?? '';
$this->footerSubline = $content['subline'] ?? '';
$this->footerUrl = $content['url'] ?? '';
$this->footerIsActive = $isActive;
}
/**
* @param array<string, mixed> $content
*/
private function loadMediaContent(array $content, bool $isActive): 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 = $isActive;
}
/**
* @param array<string, mixed> $content
*/
private function loadSlideContent(array $content, bool $isActive): void
{
$this->slideShowLogo = $content['show_logo'] ?? true;
$this->slideLogoUrl = $content['logo_url'] ?? '';
$this->slideBrandText = $content['brand_text'] ?? '';
$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->slideStrikeOriginalPrice = $content['strike_original_price'] ?? false;
$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->slideBrandTagline = $content['brand_tagline'] ?? '';
$this->slideIsActive = $isActive;
// Show flags fall back to "is there content?" so slides created before
// the dynamic detail layout keep rendering exactly as they did.
$this->slideShowBadge = $content['show_badge'] ?? ($content['badge_text'] ?? '') !== '';
$this->slideShowEyebrow = $content['show_eyebrow'] ?? ($content['eyebrow'] ?? '') !== '';
$this->slideShowSubline = $content['show_subline'] ?? ($content['subline'] ?? '') !== '';
$this->slideShowBullets = $content['show_bullets'] ?? ! empty($content['bullets']);
$this->slideShowPrice = $content['show_price'] ?? ($content['price'] ?? '') !== '';
$this->slideShowDisclaimer = $content['show_disclaimer'] ?? ($content['disclaimer'] ?? '') !== '';
$this->slideShowQr = $content['show_qr'] ?? ($content['qr_url'] ?? '') !== '';
$this->slideShowContact = $content['show_contact'] ?? ($content['contact'] ?? '') !== '';
}
/**
* @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' => 'detail',
'show_logo' => $this->slideShowLogo,
'logo_url' => $this->slideLogoUrl,
'brand_text' => $this->slideBrandText,
'duration' => $this->slideDuration,
'image_url' => $this->slideImageUrl,
'badge_text' => $this->slideBadge,
'show_badge' => $this->slideShowBadge,
'eyebrow' => $this->slideEyebrow,
'show_eyebrow' => $this->slideShowEyebrow,
'title' => $this->slideTitle,
'subline' => $this->slideSubline,
'show_subline' => $this->slideShowSubline,
'price' => $this->slidePrice,
'original_price' => $this->slideOriginalPrice,
'strike_original_price' => $this->slideStrikeOriginalPrice,
'tag_text' => $this->slideTagText,
'show_price' => $this->slideShowPrice,
'bullets' => array_values(array_filter($this->slideBullets, fn (string $bullet) => trim($bullet) !== '')),
'show_bullets' => $this->slideShowBullets,
'disclaimer' => $this->slideDisclaimer,
'show_disclaimer' => $this->slideShowDisclaimer,
'qr_url' => $this->slideQrUrl,
'qr_title' => $this->slideQrTitle,
'show_qr' => $this->slideShowQr,
'contact' => $this->slideContact,
'show_contact' => $this->slideShowContact,
'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->slideShowLogo = true;
$this->slideLogoUrl = '';
$this->slideBrandText = '';
$this->slideDuration = 8000;
$this->slideImageUrl = '';
$this->slideBadge = '';
$this->slideShowBadge = true;
$this->slideEyebrow = '';
$this->slideShowEyebrow = true;
$this->slideTitle = '';
$this->slideSubline = '';
$this->slideShowSubline = false;
$this->slidePrice = '';
$this->slideOriginalPrice = '';
$this->slideStrikeOriginalPrice = false;
$this->slideTagText = '';
$this->slideShowPrice = false;
$this->slideBullets = [];
$this->slideShowBullets = true;
$this->slideDisclaimer = '';
$this->slideShowDisclaimer = false;
$this->slideQrUrl = '';
$this->slideQrTitle = '';
$this->slideShowQr = true;
$this->slideContact = '';
$this->slideShowContact = true;
$this->slideBrandTagline = '';
$this->slideIsActive = true;
}
private function refreshModulePreview(): void
{
$this->previewFrameRefreshCounter++;
}
/**
* @return array<string, mixed>
*/
private function settingsWithDefaults(): array
{
return DisplayModuleSettings::merge($this->version->type, $this->version->settings);
}
public function render()
{
$items = $this->version->items()->get()->groupBy('item_type');
return view('livewire.admin.cms.display-version-editor', [
'items' => $items,
]);
}
}