From 7491d20d3911b9670878508ec105bafbb6840d64 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Thu, 18 Jun 2026 08:27:44 +0000 Subject: [PATCH] Display CMS: Footer-Verlauf, Format-Hinweise und Switch-Fixes. Blendet den B2IN-Text-Verlauf ohne Footer aus, zeigt 9:16-Format-Badges in der Mediathek, normalisiert Modul-Booleans und korrigiert Flux-Switches im Dark Mode. Co-authored-by: Cursor --- .../Admin/Cms/DisplayVersionEditor.php | 46 +++++++++++ app/Models/DisplayMedia.php | 77 ++++++++++++++++++- app/Services/DisplayMediaService.php | 44 +++++++++++ app/Support/DisplayModuleSettings.php | 48 +++++++++++- public/_cabinet/display/index.html | 9 ++- resources/css/portal.css | 14 ++++ .../cms/display-version-editor.blade.php | 20 +++++ .../partials/version-editor-b2in.blade.php | 8 ++ .../version-editor-settings.blade.php | 8 +- .../partials/version-editor-video.blade.php | 6 ++ tests/Feature/DisplayMediaTest.php | 18 +++++ tests/Feature/DisplayVersionApiTest.php | 3 +- tests/Feature/DisplayVersionTest.php | 47 +++++++++++ tests/Unit/DisplayModuleSettingsTest.php | 28 +++++++ 14 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/DisplayModuleSettingsTest.php diff --git a/app/Livewire/Admin/Cms/DisplayVersionEditor.php b/app/Livewire/Admin/Cms/DisplayVersionEditor.php index 1bee84d3..ec85b24a 100644 --- a/app/Livewire/Admin/Cms/DisplayVersionEditor.php +++ b/app/Livewire/Admin/Cms/DisplayVersionEditor.php @@ -324,6 +324,25 @@ class DisplayVersionEditor extends Component return url('/preview/module/'.$this->version->id.'/item/'.$this->itemId).'?refresh='.$this->previewFrameRefreshCounter; } + /** + * @return array{status: 'match'|'mismatch'|'unknown', label: string, color: string, description: string}|null + */ + public function displayMediaFormatStatus(?string $url): ?array + { + $url = trim((string) $url); + + if ($url === '') { + return null; + } + + return $this->displayMediaForUrl($url)?->getDisplayFormatStatus() ?? [ + 'status' => 'unknown', + 'label' => 'Format unbekannt', + 'color' => 'zinc', + 'description' => 'Dieses Medium wurde nicht in der Mediathek gefunden. Ziel ist 9:16 im Hochformat.', + ]; + } + #[On('display-media-selected')] public function onDisplayMediaSelected(string $field, ?int $mediaId, ?string $url): void { @@ -681,6 +700,33 @@ class DisplayVersionEditor extends Component $this->previewFrameRefreshCounter++; } + private function displayMediaForUrl(string $url): ?DisplayMedia + { + $mediaPath = $this->mediaPathFromUrl($url); + + return DisplayMedia::query() + ->where('external_url', $url) + ->when($mediaPath !== null, fn ($query) => $query->orWhere('path', $mediaPath)) + ->first(); + } + + private function mediaPathFromUrl(string $url): ?string + { + $path = parse_url($url, PHP_URL_PATH); + $path = is_string($path) ? $path : $url; + $path = ltrim($path, '/'); + + if (str_starts_with($path, 'storage/')) { + return substr($path, strlen('storage/')); + } + + if (str_starts_with($path, 'display-media/')) { + return $path; + } + + return null; + } + /** * @return array */ diff --git a/app/Models/DisplayMedia.php b/app/Models/DisplayMedia.php index eb910368..f3d474de 100644 --- a/app/Models/DisplayMedia.php +++ b/app/Models/DisplayMedia.php @@ -12,6 +12,10 @@ class DisplayMedia extends Model /** @use HasFactory<\Database\Factories\DisplayMediaFactory> */ use HasFactory; + private const DISPLAY_TARGET_RATIO = 9 / 16; + + private const DISPLAY_RATIO_TOLERANCE = 0.03; + protected $table = 'display_media'; protected $fillable = [ @@ -50,13 +54,13 @@ class DisplayMedia extends Model return $this->external_url; } - return Storage::disk($this->disk)->url($this->path); + return Storage::url($this->path); } public function getThumbnailUrl(): ?string { if ($this->thumbnail_path) { - return Storage::disk($this->disk)->url($this->thumbnail_path); + return Storage::url($this->thumbnail_path); } if ($this->isUpload() && $this->isImage()) { @@ -66,6 +70,57 @@ class DisplayMedia extends Model return null; } + /** + * @return array{status: 'match'|'mismatch'|'unknown', label: string, color: string, description: string} + */ + public function getDisplayFormatStatus(): array + { + $dimensions = $this->getDimensions(); + + if ($dimensions === null) { + return [ + 'status' => 'unknown', + 'label' => 'Format unbekannt', + 'color' => 'zinc', + 'description' => 'Keine Abmessungen gespeichert. Ziel ist 9:16 im Hochformat.', + ]; + } + + $ratio = $dimensions['width'] / $dimensions['height']; + $matchesTarget = abs($ratio - self::DISPLAY_TARGET_RATIO) <= self::DISPLAY_TARGET_RATIO * self::DISPLAY_RATIO_TOLERANCE; + $description = sprintf( + '%dx%d px, erkanntes Verhältnis %s. Ziel ist 9:16 im Hochformat.', + $dimensions['width'], + $dimensions['height'], + $this->formatRatio($dimensions['width'], $dimensions['height']), + ); + + return [ + 'status' => $matchesTarget ? 'match' : 'mismatch', + 'label' => $matchesTarget ? 'Format passt' : 'Format passt nicht', + 'color' => $matchesTarget ? 'green' : 'amber', + 'description' => $description, + ]; + } + + /** + * @return array{width: int, height: int}|null + */ + public function getDimensions(): ?array + { + $width = $this->metadata['width'] ?? null; + $height = $this->metadata['height'] ?? null; + + if (! is_numeric($width) || ! is_numeric($height) || (int) $width <= 0 || (int) $height <= 0) { + return null; + } + + return [ + 'width' => (int) $width, + 'height' => (int) $height, + ]; + } + // ======================================== // TYPE CHECKS // ======================================== @@ -117,6 +172,24 @@ class DisplayMedia extends Model return $this->title ?: $this->filename; } + private function formatRatio(int $width, int $height): string + { + $divisor = $this->greatestCommonDivisor($width, $height); + + return ($width / $divisor).':'.($height / $divisor); + } + + private function greatestCommonDivisor(int $first, int $second): int + { + while ($second !== 0) { + $remainder = $first % $second; + $first = $second; + $second = $remainder; + } + + return max($first, 1); + } + // ======================================== // SCOPES // ======================================== diff --git a/app/Services/DisplayMediaService.php b/app/Services/DisplayMediaService.php index f62c459d..b1a7f3c7 100644 --- a/app/Services/DisplayMediaService.php +++ b/app/Services/DisplayMediaService.php @@ -33,6 +33,13 @@ class DisplayMediaService $metadata['width'] = $dimensions[0]; $metadata['height'] = $dimensions[1]; } + } else { + $dimensions = $this->readVideoDimensions($file->getRealPath()); + + if ($dimensions !== null) { + $metadata['width'] = $dimensions['width']; + $metadata['height'] = $dimensions['height']; + } } $media = DisplayMedia::create([ @@ -122,6 +129,43 @@ class DisplayMediaService return $process->isSuccessful() && is_file($thumbnailPath); } + /** + * @return array{width: int, height: int}|null + */ + private function readVideoDimensions(string $videoPath): ?array + { + $process = new Process([ + 'ffprobe', + '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=width,height', + '-of', 'csv=s=x:p=0', + $videoPath, + ]); + $process->setTimeout(30); + + try { + $process->run(); + } catch (\Throwable) { + return null; + } + + if (! $process->isSuccessful()) { + return null; + } + + $dimensions = trim($process->getOutput()); + + if (! preg_match('/^(?\d+)x(?\d+)$/', $dimensions, $matches)) { + return null; + } + + return [ + 'width' => (int) $matches['width'], + 'height' => (int) $matches['height'], + ]; + } + /** * Create a DisplayMedia record from an external URL. */ diff --git a/app/Support/DisplayModuleSettings.php b/app/Support/DisplayModuleSettings.php index f33b8375..de9badb5 100644 --- a/app/Support/DisplayModuleSettings.php +++ b/app/Support/DisplayModuleSettings.php @@ -6,6 +6,14 @@ use App\Enums\DisplayVersionType; class DisplayModuleSettings { + /** @var list */ + private const B2IN_BOOLEAN_KEYS = [ + 'show_logo', + 'show_claim', + 'show_footer', + 'display_active', + ]; + /** * @return array */ @@ -55,6 +63,44 @@ class DisplayModuleSettings */ public static function merge(DisplayVersionType|string $type, ?array $settings): array { - return array_replace_recursive(self::defaults($type), $settings ?? []); + $merged = array_replace_recursive(self::defaults($type), $settings ?? []); + + $typeValue = $type instanceof DisplayVersionType ? $type->value : $type; + + if ($typeValue === DisplayVersionType::B2in->value) { + return self::normalizeB2inBooleans($merged); + } + + if ($typeValue === DisplayVersionType::Offers->value && array_key_exists('loop', $merged)) { + $merged['loop'] = self::toBool($merged['loop']); + } + + return $merged; + } + + /** + * @param array $settings + * @return array + */ + private static function normalizeB2inBooleans(array $settings): array + { + foreach (self::B2IN_BOOLEAN_KEYS as $key) { + if (array_key_exists($key, $settings)) { + $settings[$key] = self::toBool($settings[$key]); + } + } + + return $settings; + } + + private static function toBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + $filtered = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + return $filtered ?? (bool) $value; } } diff --git a/public/_cabinet/display/index.html b/public/_cabinet/display/index.html index f0c5987b..f74e429d 100644 --- a/public/_cabinet/display/index.html +++ b/public/_cabinet/display/index.html @@ -189,8 +189,6 @@ font-size: 1.8vh; font-weight: 300; color: rgba(255,255,255,0.7); line-height: 1.4; } - /* Without footer the text reclaims the footer's space at the bottom */ - .b2in-layer.no-footer .b2in-text { padding-bottom: 4vh; } /* B2in Footer */ .b2in-footer { @@ -237,6 +235,13 @@ } .b2in-layer[data-theme="light"] .b2in-footer-name { color: rgba(43,63,81,0.5); } + /* Without footer the text reclaims the footer's space and loses the bottom scrim */ + .b2in-layer.no-footer .b2in-text, + .b2in-layer.no-footer[data-theme="light"] .b2in-text { + padding-bottom: 4vh; + background: none; + } + /* ======================================== OFFERS TYPE (DOM-based slides) ======================================== */ diff --git a/resources/css/portal.css b/resources/css/portal.css index dc9c97f3..f5d23558 100644 --- a/resources/css/portal.css +++ b/resources/css/portal.css @@ -118,5 +118,19 @@ textarea[data-flux-control]::placeholder { scroll-behavior: smooth; } +/* + * Flux switch thumb in dark mode: track uses data-checked on ui-switch.group, thumb uses + * group-data-checked:translate-x-[15px]. If dark:translate-x-[2px] wins (stale views/CSS), + * the knob stays left while the track looks "on". Attribute-based rules avoid that. + */ +ui-switch.group[data-checked] > span { + --tw-translate-x: 15px; + translate: var(--tw-translate-x) var(--tw-translate-y); +} + +ui-switch.group[data-checked] > span:where(:dir(rtl), [dir='rtl'], [dir='rtl'] *) { + --tw-translate-x: -15px; +} + /* Theme-specific button styles for Backend Portal */ diff --git a/resources/views/livewire/admin/cms/display-version-editor.blade.php b/resources/views/livewire/admin/cms/display-version-editor.blade.php index 70e39924..6b7b4ba6 100644 --- a/resources/views/livewire/admin/cms/display-version-editor.blade.php +++ b/resources/views/livewire/admin/cms/display-version-editor.blade.php @@ -126,6 +126,16 @@ + @if($formatStatus = $this->displayMediaFormatStatus($videoFilename)) +
+ + {{ __($formatStatus['label']) }} + + + {{ $formatStatus['description'] }} + +
+ @endif {{ $mediaType === 'video' ? __('Video') : __('Bild') }} + @if($formatStatus = $this->displayMediaFormatStatus($mediaUrl)) + + {{ __($formatStatus['label']) }} + + @endif {{ __('wird automatisch aus dem gewählten Medium erkannt') }} + @if($formatStatus = $this->displayMediaFormatStatus($mediaUrl)) + + {{ $formatStatus['description'] }} + + @endif diff --git a/resources/views/livewire/admin/cms/partials/version-editor-b2in.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-b2in.blade.php index 487334b0..fde7dc72 100644 --- a/resources/views/livewire/admin/cms/partials/version-editor-b2in.blade.php +++ b/resources/views/livewire/admin/cms/partials/version-editor-b2in.blade.php @@ -52,6 +52,11 @@ {{ ($item->content['media_type'] ?? 'image') === 'video' ? 'Video' : 'Bild' }} + @if($formatStatus = $this->displayMediaFormatStatus($item->content['media_url'] ?? '')) + + {{ __($formatStatus['label']) }} + + @endif {{ ucfirst($item->content['category'] ?? '–') }} @@ -62,6 +67,9 @@ @if(($item->content['media_type'] ?? 'image') === 'image') {{ $item->content['duration_seconds'] ?? 10 }}s @endif + @if($formatStatus = $this->displayMediaFormatStatus($item->content['media_url'] ?? '')) + {{ $formatStatus['description'] }} + @endif diff --git a/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php index 1c61b448..bb4047e2 100644 --- a/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php +++ b/resources/views/livewire/admin/cms/partials/version-editor-settings.blade.php @@ -32,8 +32,8 @@
- - + +
@if($showLogo) @@ -63,7 +63,7 @@
{{ __('Footer & QR') }} - @@ -78,7 +78,7 @@ - + @elseif($version->type->value === 'offers') diff --git a/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php b/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php index 53f59aec..edb153ff 100644 --- a/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php +++ b/resources/views/livewire/admin/cms/partials/version-editor-video.blade.php @@ -58,6 +58,12 @@ {{ $isMediaLibrarySource ? __('Mediathek') : __('Legacy-Datei') }} + @if ($formatStatus = $this->displayMediaFormatStatus($videoSource)) + + {{ __($formatStatus['label']) }} + + {{ $formatStatus['description'] }} + @endif {{ $videoSource ?: '–' }} Position: {{ $item->content['position'] ?? 25 }}%
diff --git a/tests/Feature/DisplayMediaTest.php b/tests/Feature/DisplayMediaTest.php index 2ebc5683..d63de487 100644 --- a/tests/Feature/DisplayMediaTest.php +++ b/tests/Feature/DisplayMediaTest.php @@ -91,6 +91,23 @@ it('returns display name from title or filename', function () { expect($withoutTitle->getDisplayName())->toBe('file.mp4'); }); +it('reports whether media dimensions match the portrait display format', function () { + $portrait = DisplayMedia::factory()->create([ + 'metadata' => ['width' => 1080, 'height' => 1920], + ]); + $landscape = DisplayMedia::factory()->create([ + 'metadata' => ['width' => 1920, 'height' => 1080], + ]); + $unknown = DisplayMedia::factory()->create(['metadata' => null]); + + expect($portrait->getDisplayFormatStatus()) + ->toMatchArray(['status' => 'match', 'label' => 'Format passt']) + ->and($landscape->getDisplayFormatStatus()) + ->toMatchArray(['status' => 'mismatch', 'label' => 'Format passt nicht']) + ->and($unknown->getDisplayFormatStatus()) + ->toMatchArray(['status' => 'unknown', 'label' => 'Format unbekannt']); +}); + it('returns the stored poster as thumbnail url for a video', function () { $media = DisplayMedia::factory()->video()->create([ 'thumbnail_path' => 'display-media/2026/05/tour-poster.jpg', @@ -229,6 +246,7 @@ it('generates a poster thumbnail when a real video is uploaded', function () { $media = $service->storeUpload($file); expect($media->thumbnail_path)->not->toBeNull(); + expect($media->metadata)->toMatchArray(['width' => 320, 'height' => 240]); expect(Storage::disk('public')->exists($media->thumbnail_path))->toBeTrue(); @unlink($videoPath); diff --git a/tests/Feature/DisplayVersionApiTest.php b/tests/Feature/DisplayVersionApiTest.php index 51688c75..37d20b05 100644 --- a/tests/Feature/DisplayVersionApiTest.php +++ b/tests/Feature/DisplayVersionApiTest.php @@ -477,7 +477,8 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () { ->toContain('this.settings.claim_position') ->toContain('b2in-brand-logo pos-') ->toContain('b2in-scrim') - ->toContain('.b2in-layer.no-footer .b2in-text'); + ->toContain('.b2in-layer.no-footer .b2in-text') + ->toContain('background: none'); }); test('display player stops looping when the playlist has no playable content', function () { diff --git a/tests/Feature/DisplayVersionTest.php b/tests/Feature/DisplayVersionTest.php index a20a124c..01ffa33e 100644 --- a/tests/Feature/DisplayVersionTest.php +++ b/tests/Feature/DisplayVersionTest.php @@ -756,6 +756,53 @@ test('selecting an image medium auto-sets the media type to image', function () ->assertSet('mediaType', 'image'); }); +test('b2in media editor shows a matching portrait format badge', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'b2in']); + $media = DisplayMedia::factory()->create([ + 'type' => 'image', + 'metadata' => ['width' => 1080, 'height' => 1920], + ]); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openItemModal', null, 'media') + ->call('onDisplayMediaSelected', 'mediaUrl', $media->id, $media->getUrl()) + ->assertSee('Format passt') + ->assertSee('1080x1920 px'); +}); + +test('b2in media editor warns for landscape media', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'b2in']); + $media = DisplayMedia::factory()->create([ + 'type' => 'image', + 'metadata' => ['width' => 1920, 'height' => 1080], + ]); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openItemModal', null, 'media') + ->call('onDisplayMediaSelected', 'mediaUrl', $media->id, $media->getUrl()) + ->assertSee('Format passt nicht') + ->assertSee('1920x1080 px'); +}); + +test('video display editor warns for non portrait video media', function () { + $user = User::factory()->create(); + $version = DisplayVersion::factory()->create(['type' => 'video-display']); + $media = DisplayMedia::factory()->video()->create([ + 'metadata' => ['width' => 1920, 'height' => 1080], + ]); + + Livewire::actingAs($user) + ->test(DisplayVersionEditor::class, ['displayVersion' => $version]) + ->call('openItemModal', null, 'video') + ->call('onDisplayMediaSelected', 'videoFilename', $media->id, $media->getUrl()) + ->assertSee('Format passt nicht') + ->assertSee('1920x1080 px'); +}); + test('media item modal offers the sonstiges category and an automatic type hint', function () { $user = User::factory()->create(); $version = DisplayVersion::factory()->create(['type' => 'b2in']); diff --git a/tests/Unit/DisplayModuleSettingsTest.php b/tests/Unit/DisplayModuleSettingsTest.php new file mode 100644 index 00000000..0094a56c --- /dev/null +++ b/tests/Unit/DisplayModuleSettingsTest.php @@ -0,0 +1,28 @@ + 1, + 'show_claim' => 0, + 'show_footer' => 'true', + 'display_active' => 'false', + ]); + + expect($merged['show_logo'])->toBeTrue() + ->and($merged['show_claim'])->toBeFalse() + ->and($merged['show_footer'])->toBeTrue() + ->and($merged['display_active'])->toBeFalse(); +}); + +test('merge normalizes offers loop boolean', function () { + $merged = DisplayModuleSettings::merge(DisplayVersionType::Offers, [ + 'loop' => 0, + ]); + + expect($merged['loop'])->toBeFalse(); +});