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>
This commit is contained in:
parent
9262132325
commit
6c6d683b9a
42 changed files with 2267 additions and 13905 deletions
59
app/Console/Commands/GenerateVideoThumbnails.php
Normal file
59
app/Console/Commands/GenerateVideoThumbnails.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\DisplayMedia;
|
||||
use App\Services\DisplayMediaService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GenerateVideoThumbnails extends Command
|
||||
{
|
||||
protected $signature = 'display-media:generate-video-thumbnails
|
||||
{--force : Regenerate posters even if a thumbnail already exists}';
|
||||
|
||||
protected $description = 'Generate poster frames for uploaded display media videos using ffmpeg';
|
||||
|
||||
public function handle(DisplayMediaService $service): int
|
||||
{
|
||||
$query = DisplayMedia::query()
|
||||
->where('type', 'video')
|
||||
->where('source_type', 'upload');
|
||||
|
||||
if (! $this->option('force')) {
|
||||
$query->whereNull('thumbnail_path');
|
||||
}
|
||||
|
||||
$videos = $query->get();
|
||||
|
||||
if ($videos->isEmpty()) {
|
||||
$this->info('Keine Videos zum Verarbeiten gefunden.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf('%d Video(s) werden verarbeitet...', $videos->count()));
|
||||
|
||||
$generated = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($videos as $video) {
|
||||
$thumbnailPath = $service->generateVideoThumbnail($video);
|
||||
|
||||
if ($thumbnailPath !== null) {
|
||||
$video->update(['thumbnail_path' => $thumbnailPath]);
|
||||
$this->line(" <info>✓</info> {$video->getDisplayName()}");
|
||||
$generated++;
|
||||
} else {
|
||||
$this->warn(" ✗ {$video->getDisplayName()} (Poster konnte nicht erzeugt werden)");
|
||||
$failed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info(sprintf('Fertig: %d erzeugt, %d fehlgeschlagen.', $generated, $failed));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,9 @@ class DisplayPreviewController extends Controller
|
|||
->where('preview_token', $token)
|
||||
->firstOrFail();
|
||||
|
||||
return response()->file(public_path('_cabinet/display/index.html'));
|
||||
return response()->file(public_path('_cabinet/display/index.html'), [
|
||||
'Cache-Control' => 'no-cache, must-revalidate',
|
||||
]);
|
||||
}
|
||||
|
||||
public function config(string $token, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
|
||||
|
|
|
|||
|
|
@ -13,14 +13,21 @@ class ModulePreviewController extends Controller
|
|||
{
|
||||
public function show(DisplayVersion $module): BinaryFileResponse
|
||||
{
|
||||
return response()->file(public_path('_cabinet/display/index.html'));
|
||||
return $this->playerResponse();
|
||||
}
|
||||
|
||||
public function showItem(DisplayVersion $module, DisplayVersionItem $item): BinaryFileResponse
|
||||
{
|
||||
abort_unless($item->display_version_id === $module->id, 404);
|
||||
|
||||
return response()->file(public_path('_cabinet/display/index.html'));
|
||||
return $this->playerResponse();
|
||||
}
|
||||
|
||||
private function playerResponse(): BinaryFileResponse
|
||||
{
|
||||
return response()->file(public_path('_cabinet/display/index.html'), [
|
||||
'Cache-Control' => 'no-cache, must-revalidate',
|
||||
]);
|
||||
}
|
||||
|
||||
public function config(DisplayVersion $module, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ class DisplayList extends Component
|
|||
|
||||
public $addVersionSelect = null;
|
||||
|
||||
/** @var array<int> */
|
||||
public $versionsToAdd = [];
|
||||
|
||||
public $editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED;
|
||||
|
||||
public ?string $draftPreviewToken = null;
|
||||
|
|
@ -75,6 +78,20 @@ class DisplayList extends Component
|
|||
$this->persistDraftPreviewIfNeeded();
|
||||
}
|
||||
|
||||
public function addSelectedVersions(): void
|
||||
{
|
||||
foreach ($this->versionsToAdd as $versionId) {
|
||||
$id = (int) $versionId;
|
||||
|
||||
if ($id && ! in_array($id, $this->selectedVersionIds, true)) {
|
||||
$this->selectedVersionIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
$this->versionsToAdd = [];
|
||||
$this->persistDraftPreviewIfNeeded();
|
||||
}
|
||||
|
||||
private function firstAvailableVersionId(): ?int
|
||||
{
|
||||
return DisplayVersion::active()
|
||||
|
|
@ -175,10 +192,31 @@ class DisplayList extends Component
|
|||
}
|
||||
|
||||
$display->draftPlaylist->delete();
|
||||
$display->clearPreviewToken();
|
||||
|
||||
session()->flash('success', 'Entwurf wurde verworfen.');
|
||||
}
|
||||
|
||||
public function rotatePreviewToken(int $displayId): void
|
||||
{
|
||||
$display = Display::with('draftPlaylist')->findOrFail($displayId);
|
||||
|
||||
if (! $display->draftPlaylist) {
|
||||
session()->flash('success', 'Für dieses Display gibt es keinen Entwurf.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$display->rotatePreviewToken();
|
||||
|
||||
if ($this->displayId === $display->id && $this->editingPlaylistStatus === DisplayPlaylist::STATUS_DRAFT) {
|
||||
$this->draftPreviewToken = $display->preview_token;
|
||||
$this->previewFrameRefreshCounter++;
|
||||
}
|
||||
|
||||
session()->flash('success', 'Vorschau-Link wurde neu erzeugt. Der alte Link ist jetzt ungültig.');
|
||||
}
|
||||
|
||||
public function publishDraft(int $displayId): void
|
||||
{
|
||||
$display = Display::with(['draftPlaylist.modules'])->findOrFail($displayId);
|
||||
|
|
@ -201,6 +239,8 @@ class DisplayList extends Component
|
|||
return $display->draftPlaylist->fresh('modules');
|
||||
});
|
||||
|
||||
$display->clearPreviewToken();
|
||||
|
||||
session()->flash('success', 'Entwurf wurde veröffentlicht.');
|
||||
}
|
||||
|
||||
|
|
@ -314,6 +354,7 @@ class DisplayList extends Component
|
|||
$this->displayIsActive = true;
|
||||
$this->displayIsTest = false;
|
||||
$this->addVersionSelect = null;
|
||||
$this->versionsToAdd = [];
|
||||
$this->editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED;
|
||||
$this->draftPreviewToken = null;
|
||||
$this->previewFrameRefreshCounter = 0;
|
||||
|
|
|
|||
|
|
@ -117,8 +117,7 @@ class DisplayMediaPicker extends Component
|
|||
->active()
|
||||
->when($this->type === 'image', fn ($q) => $q->images())
|
||||
->when($this->type === 'video', fn ($q) => $q->videos())
|
||||
->when($this->search, fn ($q) => $q->where('filename', 'like', "%{$this->search}%")
|
||||
->orWhere('title', 'like', "%{$this->search}%"))
|
||||
->when($this->search, fn ($q) => $q->search($this->search))
|
||||
->orderByDesc('created_at')
|
||||
->paginate(18);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
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 Illuminate\Support\Facades\File;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -56,8 +56,12 @@ class DisplayVersionEditor extends Component
|
|||
|
||||
public bool $mediaIsActive = true;
|
||||
|
||||
// Offers: Slide fields
|
||||
public string $slideType = 'product-hero';
|
||||
// Offers: Slide fields (single dynamic detail layout)
|
||||
public bool $slideShowLogo = true;
|
||||
|
||||
public string $slideLogoUrl = '';
|
||||
|
||||
public string $slideBrandText = '';
|
||||
|
||||
public int $slideDuration = 8000;
|
||||
|
||||
|
|
@ -65,30 +69,46 @@ class DisplayVersionEditor extends Component
|
|||
|
||||
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 $slideShowBrandText = false;
|
||||
public bool $slideShowContact = true;
|
||||
|
||||
public string $slideBrandTagline = '';
|
||||
|
||||
|
|
@ -99,32 +119,25 @@ class DisplayVersionEditor extends Component
|
|||
|
||||
public array $settings = [];
|
||||
|
||||
/** @var array<string> */
|
||||
public array $availableVideos = [];
|
||||
|
||||
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();
|
||||
|
||||
if ($this->version->type === DisplayVersionType::VideoDisplay) {
|
||||
$this->loadAvailableVideos();
|
||||
}
|
||||
$this->normalizeBrandPositions();
|
||||
}
|
||||
|
||||
public function loadAvailableVideos(): void
|
||||
public function updated(string $name): 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();
|
||||
if (str_starts_with($name, 'settings.show_footer')
|
||||
|| str_starts_with($name, 'settings.logo_position')
|
||||
|| str_starts_with($name, 'settings.claim_position')) {
|
||||
$this->normalizeBrandPositions();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,12 +173,56 @@ class DisplayVersionEditor extends Component
|
|||
|
||||
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
|
||||
// ========================================
|
||||
|
|
@ -276,10 +333,20 @@ class DisplayVersionEditor extends Component
|
|||
'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,
|
||||
'settings.logo_url' => $this->settings['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
|
||||
|
|
@ -306,33 +373,43 @@ class DisplayVersionEditor extends Component
|
|||
private function loadItemContent(DisplayVersionItem $item): void
|
||||
{
|
||||
$content = $item->content;
|
||||
$isActive = (bool) $item->is_active;
|
||||
|
||||
match ($item->item_type) {
|
||||
'video' => $this->loadVideoContent($content),
|
||||
'footer' => $this->loadFooterContent($content),
|
||||
'media' => $this->loadMediaContent($content),
|
||||
'slide' => $this->loadSlideContent($content),
|
||||
'video' => $this->loadVideoContent($content, $isActive),
|
||||
'footer' => $this->loadFooterContent($content, $isActive),
|
||||
'media' => $this->loadMediaContent($content, $isActive),
|
||||
'slide' => $this->loadSlideContent($content, $isActive),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function loadVideoContent(array $content): void
|
||||
/**
|
||||
* @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 = true;
|
||||
$this->videoIsActive = $isActive;
|
||||
}
|
||||
|
||||
private function loadFooterContent(array $content): void
|
||||
/**
|
||||
* @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 = true;
|
||||
$this->footerIsActive = $isActive;
|
||||
}
|
||||
|
||||
private function loadMediaContent(array $content): void
|
||||
/**
|
||||
* @param array<string, mixed> $content
|
||||
*/
|
||||
private function loadMediaContent(array $content, bool $isActive): void
|
||||
{
|
||||
$this->mediaType = $content['media_type'] ?? 'image';
|
||||
$this->mediaCategory = $content['category'] ?? 'immobilien';
|
||||
|
|
@ -340,12 +417,17 @@ class DisplayVersionEditor extends Component
|
|||
$this->mediaHeadline = $content['headline'] ?? '';
|
||||
$this->mediaSubline = $content['subline'] ?? '';
|
||||
$this->mediaDuration = $content['duration_seconds'] ?? 10;
|
||||
$this->mediaIsActive = true;
|
||||
$this->mediaIsActive = $isActive;
|
||||
}
|
||||
|
||||
private function loadSlideContent(array $content): void
|
||||
/**
|
||||
* @param array<string, mixed> $content
|
||||
*/
|
||||
private function loadSlideContent(array $content, bool $isActive): void
|
||||
{
|
||||
$this->slideType = $content['type'] ?? 'product-hero';
|
||||
$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'] ?? '';
|
||||
|
|
@ -354,15 +436,26 @@ class DisplayVersionEditor extends Component
|
|||
$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->slideShowBrandText = $content['show_brand_text'] ?? false;
|
||||
$this->slideBrandTagline = $content['brand_tagline'] ?? '';
|
||||
$this->slideIsActive = true;
|
||||
$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'] ?? '') !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -390,22 +483,33 @@ class DisplayVersionEditor extends Component
|
|||
'duration_seconds' => $this->mediaDuration,
|
||||
],
|
||||
'slide' => [
|
||||
'type' => $this->slideType,
|
||||
'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,
|
||||
'bullets' => $this->slideBullets,
|
||||
'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_brand_text' => $this->slideShowBrandText,
|
||||
'show_contact' => $this->slideShowContact,
|
||||
'brand_tagline' => $this->slideBrandTagline,
|
||||
],
|
||||
default => [],
|
||||
|
|
@ -451,22 +555,32 @@ class DisplayVersionEditor extends Component
|
|||
$this->mediaSubline = '';
|
||||
$this->mediaDuration = 10;
|
||||
$this->mediaIsActive = true;
|
||||
$this->slideType = 'product-hero';
|
||||
$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->slideShowBrandText = false;
|
||||
$this->slideShowContact = true;
|
||||
$this->slideBrandTagline = '';
|
||||
$this->slideIsActive = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,20 @@ class DisplayVersionList extends Component
|
|||
|
||||
public function deleteVersion(int $id): void
|
||||
{
|
||||
$version = DisplayVersion::findOrFail($id);
|
||||
$version = DisplayVersion::query()
|
||||
->withCount([
|
||||
'playlistItems as displays_count' => fn ($query) => $query
|
||||
->join('display_playlists', 'display_playlist_items.display_playlist_id', '=', 'display_playlists.id')
|
||||
->select(DB::raw('count(distinct display_playlists.display_id)')),
|
||||
])
|
||||
->findOrFail($id);
|
||||
|
||||
if ($version->displays_count > 0) {
|
||||
session()->flash('error', 'Modul "'.$version->name.'" wird noch von '.$version->displays_count.' Display(s) verwendet und kann nicht gelöscht werden. Entfernen Sie es zuerst aus den betroffenen Bespielungen.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $version->name;
|
||||
$version->delete();
|
||||
|
||||
|
|
|
|||
|
|
@ -45,10 +45,10 @@ class QuickStatus extends Component
|
|||
],
|
||||
];
|
||||
|
||||
public function mount(): void
|
||||
public function mount(?string $k = null): void
|
||||
{
|
||||
$validKey = config('domains.cabinet_status_key');
|
||||
$key = request()->get('key');
|
||||
$key = $k ?? request()->query('k');
|
||||
if (! $validKey || $key !== $validKey) {
|
||||
$this->authorized = false;
|
||||
|
||||
|
|
@ -92,6 +92,13 @@ class QuickStatus extends Component
|
|||
'noticeSubtext.max' => 'Subtext max. 80 Zeichen.',
|
||||
]);
|
||||
|
||||
$showsNotice = in_array($this->storeStatus, ['notice', 'warning'], true);
|
||||
|
||||
if (! $showsNotice) {
|
||||
$this->noticeHeadline = '';
|
||||
$this->noticeSubtext = '';
|
||||
}
|
||||
|
||||
CabinetTabletSetting::current()->update([
|
||||
'store_status' => $this->storeStatus,
|
||||
'notice_headline' => $this->noticeHeadline ?: null,
|
||||
|
|
|
|||
|
|
@ -64,4 +64,20 @@ class Display extends Model
|
|||
|
||||
return $this->preview_token;
|
||||
}
|
||||
|
||||
public function rotatePreviewToken(): string
|
||||
{
|
||||
$this->preview_token = Str::random(40);
|
||||
$this->save();
|
||||
|
||||
return $this->preview_token;
|
||||
}
|
||||
|
||||
public function clearPreviewToken(): void
|
||||
{
|
||||
if ($this->preview_token !== null) {
|
||||
$this->preview_token = null;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,4 +150,12 @@ class DisplayMedia extends Model
|
|||
{
|
||||
return $query->where('collection', $collection);
|
||||
}
|
||||
|
||||
public function scopeSearch(Builder $query, string $term): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query) use ($term): void {
|
||||
$query->where('filename', 'like', "%{$term}%")
|
||||
->orWhere('title', 'like', "%{$term}%");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use Illuminate\Http\UploadedFile;
|
|||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class DisplayMediaService
|
||||
{
|
||||
|
|
@ -34,7 +35,7 @@ class DisplayMediaService
|
|||
}
|
||||
}
|
||||
|
||||
return DisplayMedia::create([
|
||||
$media = DisplayMedia::create([
|
||||
'filename' => $filename,
|
||||
'disk' => 'public',
|
||||
'path' => $relativePath,
|
||||
|
|
@ -45,6 +46,80 @@ class DisplayMediaService
|
|||
'collection' => $collection,
|
||||
'metadata' => ! empty($metadata) ? $metadata : null,
|
||||
]);
|
||||
|
||||
if ($type === 'video') {
|
||||
$thumbnailPath = $this->generateVideoThumbnail($media);
|
||||
|
||||
if ($thumbnailPath !== null) {
|
||||
$media->update(['thumbnail_path' => $thumbnailPath]);
|
||||
}
|
||||
}
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a poster frame for an uploaded video using ffmpeg.
|
||||
*
|
||||
* Returns the relative thumbnail path on the media's disk, or null when
|
||||
* generation is not possible (e.g. ffmpeg missing or an unreadable file).
|
||||
*/
|
||||
public function generateVideoThumbnail(DisplayMedia $media): ?string
|
||||
{
|
||||
if (! $media->isVideo() || ! $media->isUpload() || ! $media->path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($media->disk);
|
||||
$videoPath = $disk->path($media->path);
|
||||
|
||||
if (! is_file($videoPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$thumbnailRelativePath = preg_replace('/\.[^.\/]+$/', '', $media->path).'-poster.jpg';
|
||||
$thumbnailPath = $disk->path($thumbnailRelativePath);
|
||||
|
||||
if (! is_dir(dirname($thumbnailPath))) {
|
||||
@mkdir(dirname($thumbnailPath), 0755, true);
|
||||
}
|
||||
|
||||
// Try a frame ~1s in first (avoids black intro frames); fall back to
|
||||
// the very first frame for clips shorter than one second.
|
||||
foreach (['1', '0'] as $seekSeconds) {
|
||||
if ($this->extractFrame($videoPath, $thumbnailPath, $seekSeconds) && filesize($thumbnailPath) > 0) {
|
||||
return $thumbnailRelativePath;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_file($thumbnailPath)) {
|
||||
@unlink($thumbnailPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractFrame(string $videoPath, string $thumbnailPath, string $seekSeconds): bool
|
||||
{
|
||||
$process = new Process([
|
||||
'ffmpeg',
|
||||
'-y',
|
||||
'-ss', $seekSeconds,
|
||||
'-i', $videoPath,
|
||||
'-frames:v', '1',
|
||||
'-vf', 'scale=640:-2',
|
||||
'-q:v', '3',
|
||||
$thumbnailPath,
|
||||
]);
|
||||
$process->setTimeout(60);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $process->isSuccessful() && is_file($thumbnailPath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -143,25 +143,40 @@ class DisplayPlaylistConfigBuilder
|
|||
*/
|
||||
private function offersData(DisplayVersion $module, Collection $items): array
|
||||
{
|
||||
$slides = $items->where('item_type', 'slide')->values()->map(fn ($item) => [
|
||||
'type' => $item->content['type'] ?? 'product-hero',
|
||||
'duration' => $item->content['duration'] ?? 8000,
|
||||
'image_url' => $item->content['image_url'] ?? '',
|
||||
'badge_text' => $item->content['badge_text'] ?? '',
|
||||
'eyebrow' => $item->content['eyebrow'] ?? '',
|
||||
'title' => $item->content['title'] ?? '',
|
||||
'subline' => $item->content['subline'] ?? '',
|
||||
'price' => $item->content['price'] ?? '',
|
||||
'original_price' => $item->content['original_price'] ?? '',
|
||||
'tag_text' => $item->content['tag_text'] ?? '',
|
||||
'bullets' => $item->content['bullets'] ?? [],
|
||||
'disclaimer' => $item->content['disclaimer'] ?? '',
|
||||
'qr_url' => $item->content['qr_url'] ?? '',
|
||||
'qr_title' => $item->content['qr_title'] ?? '',
|
||||
'contact' => $item->content['contact'] ?? '',
|
||||
'show_brand_text' => $item->content['show_brand_text'] ?? false,
|
||||
'brand_tagline' => $item->content['brand_tagline'] ?? '',
|
||||
]);
|
||||
$slides = $items->where('item_type', 'slide')->values()->map(function ($item) {
|
||||
$content = $item->content;
|
||||
|
||||
return [
|
||||
'type' => $content['type'] ?? 'detail',
|
||||
'show_logo' => $content['show_logo'] ?? true,
|
||||
'logo_url' => $content['logo_url'] ?? '',
|
||||
'brand_text' => $content['brand_text'] ?? '',
|
||||
'duration' => $content['duration'] ?? 8000,
|
||||
'image_url' => $content['image_url'] ?? '',
|
||||
'badge_text' => $content['badge_text'] ?? '',
|
||||
'show_badge' => $content['show_badge'] ?? ($content['badge_text'] ?? '') !== '',
|
||||
'eyebrow' => $content['eyebrow'] ?? '',
|
||||
'show_eyebrow' => $content['show_eyebrow'] ?? ($content['eyebrow'] ?? '') !== '',
|
||||
'title' => $content['title'] ?? '',
|
||||
'subline' => $content['subline'] ?? '',
|
||||
'show_subline' => $content['show_subline'] ?? ($content['subline'] ?? '') !== '',
|
||||
'price' => $content['price'] ?? '',
|
||||
'original_price' => $content['original_price'] ?? '',
|
||||
'strike_original_price' => $content['strike_original_price'] ?? false,
|
||||
'tag_text' => $content['tag_text'] ?? '',
|
||||
'show_price' => $content['show_price'] ?? ($content['price'] ?? '') !== '',
|
||||
'bullets' => $content['bullets'] ?? [],
|
||||
'show_bullets' => $content['show_bullets'] ?? ! empty($content['bullets']),
|
||||
'disclaimer' => $content['disclaimer'] ?? '',
|
||||
'show_disclaimer' => $content['show_disclaimer'] ?? ($content['disclaimer'] ?? '') !== '',
|
||||
'qr_url' => $content['qr_url'] ?? '',
|
||||
'qr_title' => $content['qr_title'] ?? '',
|
||||
'show_qr' => $content['show_qr'] ?? ($content['qr_url'] ?? '') !== '',
|
||||
'contact' => $content['contact'] ?? '',
|
||||
'show_contact' => $content['show_contact'] ?? ($content['contact'] ?? '') !== '',
|
||||
'brand_tagline' => $content['brand_tagline'] ?? '',
|
||||
];
|
||||
});
|
||||
|
||||
return [
|
||||
'type' => 'offers',
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ class DisplayModuleSettings
|
|||
'theme' => 'dark',
|
||||
'header_logo_url' => '../assets/b2in-logo-positive.svg',
|
||||
'header_claim' => 'Connecting Design & Property',
|
||||
'logo_position' => 'top-left',
|
||||
'claim_position' => 'top-right',
|
||||
'show_logo' => true,
|
||||
'show_claim' => true,
|
||||
'show_footer' => true,
|
||||
'footer_url' => 'B2in.eu',
|
||||
'footer_name' => '',
|
||||
'footer_prefix' => 'by',
|
||||
|
|
@ -35,12 +40,6 @@ class DisplayModuleSettings
|
|||
],
|
||||
DisplayVersionType::Offers->value => [
|
||||
'loop' => true,
|
||||
'logo_url' => '../logo-cabinet-300.png',
|
||||
'brand_text' => 'Bielefeld',
|
||||
'footer_claim' => '',
|
||||
'footer_url' => '',
|
||||
'qr_default_title' => 'Kontakt',
|
||||
'qr_subtitle' => 'QR scannen',
|
||||
'transition' => [
|
||||
'type' => 'fade',
|
||||
'duration' => 600,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue