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:
Kevin Adametz 2026-05-29 15:57:33 +00:00
parent 9262132325
commit 6c6d683b9a
42 changed files with 2267 additions and 13905 deletions

View file

@ -14,6 +14,31 @@ beforeEach(function () {
Storage::fake('public');
});
/**
* Create a real, tiny mp4 on disk using ffmpeg and return the path.
* Returns null when ffmpeg is unavailable so callers can skip.
*/
function makeRealTestVideo(): ?string
{
$ffmpeg = trim((string) shell_exec('command -v ffmpeg'));
if ($ffmpeg === '') {
return null;
}
$path = tempnam(sys_get_temp_dir(), 'vid_').'.mp4';
$command = sprintf(
'%s -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -pix_fmt yuv420p %s 2>/dev/null',
escapeshellarg($ffmpeg),
escapeshellarg($path)
);
shell_exec($command);
return is_file($path) && filesize($path) > 0 ? $path : null;
}
// ========================================
// MODEL TESTS
// ========================================
@ -66,6 +91,20 @@ it('returns display name from title or filename', function () {
expect($withoutTitle->getDisplayName())->toBe('file.mp4');
});
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',
]);
expect($media->getThumbnailUrl())->toContain('display-media/2026/05/tour-poster.jpg');
});
it('has no thumbnail url for a video without a poster', function () {
$media = DisplayMedia::factory()->video()->create(['thumbnail_path' => null]);
expect($media->getThumbnailUrl())->toBeNull();
});
// ========================================
// SCOPE TESTS
// ========================================
@ -105,6 +144,41 @@ it('filters by collection scope', function () {
expect(DisplayMedia::inCollection('immobilien')->count())->toBe(1);
});
it('keeps preceding filters when combined with the search scope', function () {
DisplayMedia::factory()->create([
'type' => 'image',
'filename' => 'haus.webp',
'title' => 'Schaufenster',
]);
DisplayMedia::factory()->video()->create([
'filename' => 'tour.mp4',
'title' => 'Haus Tour',
]);
$results = DisplayMedia::images()->search('haus')->get();
expect($results)->toHaveCount(1)
->and($results->first()->filename)->toBe('haus.webp');
});
it('search scope respects the active filter on the media picker', function () {
DisplayMedia::factory()->create([
'filename' => 'aktiv.webp',
'title' => 'Goya',
'is_active' => true,
]);
DisplayMedia::factory()->create([
'filename' => 'inaktiv.webp',
'title' => 'Goya',
'is_active' => false,
]);
$results = DisplayMedia::active()->search('goya')->get();
expect($results)->toHaveCount(1)
->and($results->first()->filename)->toBe('aktiv.webp');
});
it('filters by active scope', function () {
DisplayMedia::factory()->create(['is_active' => true]);
DisplayMedia::factory()->create(['is_active' => false]);
@ -142,6 +216,33 @@ it('stores a video upload', function () {
->and($media->mime_type)->toBe('video/mp4');
});
it('generates a poster thumbnail when a real video is uploaded', function () {
$videoPath = makeRealTestVideo();
if ($videoPath === null) {
$this->markTestSkipped('ffmpeg is not available.');
}
$service = app(DisplayMediaService::class);
$file = new UploadedFile($videoPath, 'showroom.mp4', 'video/mp4', null, true);
$media = $service->storeUpload($file);
expect($media->thumbnail_path)->not->toBeNull();
expect(Storage::disk('public')->exists($media->thumbnail_path))->toBeTrue();
@unlink($videoPath);
});
it('does not set a thumbnail when the video cannot be decoded', function () {
$service = app(DisplayMediaService::class);
$file = UploadedFile::fake()->create('broken.mp4', 500, 'video/mp4');
$media = $service->storeUpload($file);
expect($media->thumbnail_path)->toBeNull();
});
it('stores an svg upload as image media', function () {
$service = app(DisplayMediaService::class);
$file = UploadedFile::fake()->createWithContent(
@ -158,6 +259,44 @@ it('stores an svg upload as image media', function () {
expect(Storage::disk('public')->exists($media->path))->toBeTrue();
});
it('resets pagination to page one when a filter changes', function () {
Volt::test('admin.cms.display-media-library')
->call('gotoPage', 3)
->set('filterType', 'video')
->assertSet('paginators.page', 1);
});
it('renders external image media with an inline thumbnail', function () {
$media = DisplayMedia::factory()->external()->create([
'type' => 'image',
'external_url' => 'https://example.com/foto.jpg',
]);
Volt::test('admin.cms.display-media-library')
->assertSeeHtml('src="https://example.com/foto.jpg"');
});
it('renders a stored poster image for a video with a thumbnail', function () {
DisplayMedia::factory()->video()->create([
'path' => 'display-media/2026/05/tour.mp4',
'thumbnail_path' => 'display-media/2026/05/tour-poster.jpg',
]);
Volt::test('admin.cms.display-media-library')
->assertSeeHtml('tour-poster.jpg');
});
it('renders an inline video frame for an uploaded video without a poster', function () {
DisplayMedia::factory()->video()->create([
'path' => 'display-media/2026/05/clip.mp4',
'thumbnail_path' => null,
'mime_type' => 'video/mp4',
]);
Volt::test('admin.cms.display-media-library')
->assertSeeHtml('clip.mp4#t=1');
});
it('accepts svg uploads in the display media library', function () {
$file = UploadedFile::fake()->createWithContent(
'brand.svg',
@ -237,6 +376,42 @@ it('deletes external media record', function () {
expect(DisplayMedia::find($media->id))->toBeNull();
});
// ========================================
// COMMAND TESTS
// ========================================
it('reports when there are no videos to process', function () {
$this->artisan('display-media:generate-video-thumbnails')
->expectsOutputToContain('Keine Videos zum Verarbeiten gefunden.')
->assertSuccessful();
});
it('backfills posters for existing uploaded videos', function () {
$videoPath = makeRealTestVideo();
if ($videoPath === null) {
$this->markTestSkipped('ffmpeg is not available.');
}
Storage::disk('public')->putFileAs(
'display-media/2026/05',
new UploadedFile($videoPath, 'clip.mp4', 'video/mp4', null, true),
'clip.mp4'
);
$media = DisplayMedia::factory()->video()->create([
'source_type' => 'upload',
'path' => 'display-media/2026/05/clip.mp4',
'thumbnail_path' => null,
]);
$this->artisan('display-media:generate-video-thumbnails')->assertSuccessful();
expect($media->fresh()->thumbnail_path)->not->toBeNull();
@unlink($videoPath);
});
// ========================================
// ROUTE TESTS
// ========================================