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 <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-06-18 08:27:44 +00:00
parent 6c2da9bdac
commit 7491d20d39
14 changed files with 366 additions and 10 deletions

View file

@ -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<string, mixed>
*/

View file

@ -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
// ========================================

View file

@ -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('/^(?<width>\d+)x(?<height>\d+)$/', $dimensions, $matches)) {
return null;
}
return [
'width' => (int) $matches['width'],
'height' => (int) $matches['height'],
];
}
/**
* Create a DisplayMedia record from an external URL.
*/

View file

@ -6,6 +6,14 @@ use App\Enums\DisplayVersionType;
class DisplayModuleSettings
{
/** @var list<string> */
private const B2IN_BOOLEAN_KEYS = [
'show_logo',
'show_claim',
'show_footer',
'display_active',
];
/**
* @return array<string, mixed>
*/
@ -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<string, mixed> $settings
* @return array<string, mixed>
*/
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;
}
}

View file

@ -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)
======================================== */

View file

@ -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 */

View file

@ -126,6 +126,16 @@
<flux:input wire:model.live.debounce.500ms="videoFilename" label="Video-Pfad / URL" placeholder="Wird automatisch gesetzt oder manuell eingeben..."
description="Über die Mediathek auswählen oder direkt Pfad/URL eingeben." class="flex-1" />
</div>
@if($formatStatus = $this->displayMediaFormatStatus($videoFilename))
<div class="flex flex-wrap items-center gap-2">
<flux:badge size="sm" :color="$formatStatus['color']">
{{ __($formatStatus['label']) }}
</flux:badge>
<flux:text class="text-xs text-zinc-500 dark:text-zinc-400">
{{ $formatStatus['description'] }}
</flux:text>
</div>
@endif
<flux:input wire:model="videoTitle" label="Name" placeholder="z.B. Herbst Kollektion 2025"
description="Interner Name des Videos – wird nicht im Video eingeblendet." />
<flux:input wire:model="videoPosition" type="number" min="0" max="100" label="Position (%)"
@ -160,8 +170,18 @@
<flux:badge size="sm" :color="$mediaType === 'video' ? 'purple' : 'sky'">
{{ $mediaType === 'video' ? __('Video') : __('Bild') }}
</flux:badge>
@if($formatStatus = $this->displayMediaFormatStatus($mediaUrl))
<flux:badge size="sm" :color="$formatStatus['color']">
{{ __($formatStatus['label']) }}
</flux:badge>
@endif
<flux:text class="text-xs text-zinc-400">{{ __('wird automatisch aus dem gewählten Medium erkannt') }}</flux:text>
</div>
@if($formatStatus = $this->displayMediaFormatStatus($mediaUrl))
<flux:text class="text-xs text-zinc-500 dark:text-zinc-400">
{{ $formatStatus['description'] }}
</flux:text>
@endif
<flux:select wire:model="mediaCategory" label="Kategorie">
<option value="immobilien">Immobilien</option>
<option value="moebel">Möbel</option>

View file

@ -52,6 +52,11 @@
<flux:badge color="{{ ($item->content['media_type'] ?? 'image') === 'video' ? 'purple' : 'sky' }}" size="sm">
{{ ($item->content['media_type'] ?? 'image') === 'video' ? 'Video' : 'Bild' }}
</flux:badge>
@if($formatStatus = $this->displayMediaFormatStatus($item->content['media_url'] ?? ''))
<flux:badge size="sm" :color="$formatStatus['color']">
{{ __($formatStatus['label']) }}
</flux:badge>
@endif
<flux:badge color="zinc" size="sm">
{{ ucfirst($item->content['category'] ?? '–') }}
</flux:badge>
@ -62,6 +67,9 @@
@if(($item->content['media_type'] ?? 'image') === 'image')
<span>{{ $item->content['duration_seconds'] ?? 10 }}s</span>
@endif
@if($formatStatus = $this->displayMediaFormatStatus($item->content['media_url'] ?? ''))
<span>{{ $formatStatus['description'] }}</span>
@endif
</div>
</div>

View file

@ -32,8 +32,8 @@
<flux:input wire:model="settings.header_claim" label="Claim" placeholder="Connecting Design & Property" />
<div class="flex flex-wrap gap-6">
<flux:switch wire:model.live="settings.show_logo" label="Logo anzeigen" />
<flux:switch wire:model.live="settings.show_claim" label="Claim anzeigen" />
<flux:switch wire:model.live.boolean="settings.show_logo" label="Logo anzeigen" />
<flux:switch wire:model.live.boolean="settings.show_claim" label="Claim anzeigen" />
</div>
@if($showLogo)
@ -63,7 +63,7 @@
</div>
<div class="space-y-4 rounded-xl border border-zinc-200 p-4 dark:border-zinc-700">
<flux:heading size="sm">{{ __('Footer & QR') }}</flux:heading>
<flux:switch wire:model.live="settings.show_footer" label="Footer anzeigen"
<flux:switch wire:model.live.boolean="settings.show_footer" label="Footer anzeigen"
description="Blendet die Fußzeile mit Domain, Name und QR-Code ein oder aus." />
<flux:input wire:model="settings.footer_prefix" label="Footer-Präfix" placeholder="by" />
<flux:input wire:model="settings.footer_name" label="Footer Name" placeholder="z.B. Marcel Scheibe" />
@ -78,7 +78,7 @@
</flux:select>
<flux:input wire:model="settings.transition.duration_ms" type="number" label="Transition-Dauer (ms)" />
<flux:input wire:model="settings.default_image_duration" type="number" label="Standard-Bilddauer (Sek.)" />
<flux:checkbox wire:model="settings.display_active" label="Display aktiv" />
<flux:checkbox wire:model.boolean="settings.display_active" label="Display aktiv" />
@elseif($version->type->value === 'offers')
<flux:checkbox wire:model="settings.loop" label="Endlosschleife" />
<flux:select wire:model="settings.transition.type" label="Transition">

View file

@ -58,6 +58,12 @@
<flux:badge size="sm" :color="$isMediaLibrarySource ? 'sky' : 'zinc'">
{{ $isMediaLibrarySource ? __('Mediathek') : __('Legacy-Datei') }}
</flux:badge>
@if ($formatStatus = $this->displayMediaFormatStatus($videoSource))
<flux:badge size="sm" :color="$formatStatus['color']">
{{ __($formatStatus['label']) }}
</flux:badge>
<span>{{ $formatStatus['description'] }}</span>
@endif
<span class="truncate">{{ $videoSource ?: '–' }}</span>
<span>Position: {{ $item->content['position'] ?? 25 }}%</span>
</div>

View file

@ -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);

View file

@ -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 () {

View file

@ -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']);

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Enums\DisplayVersionType;
use App\Support\DisplayModuleSettings;
test('merge normalizes b2in boolean settings from legacy scalar values', function () {
$merged = DisplayModuleSettings::merge(DisplayVersionType::B2in, [
'show_logo' => 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();
});