- 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>
609 lines
20 KiB
PHP
609 lines
20 KiB
PHP
<?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,
|
||
]);
|
||
}
|
||
}
|