diff --git a/.devcontainer/docker-compose.dev.yml b/.devcontainer/docker-compose.dev.yml
index df7d4f4..04f71ac 100644
--- a/.devcontainer/docker-compose.dev.yml
+++ b/.devcontainer/docker-compose.dev.yml
@@ -62,7 +62,7 @@ services:
MYSQL_EXTRA_OPTIONS: --default-authentication-plugin=mysql_native_password
volumes:
- '../:/var/www/html'
- - './php-upload-limits.ini:/etc/php/8.4/cli/conf.d/99-upload-limits.ini:ro'
+ - './php-upload-limits.ini:/etc/php/8.4/cli/conf.d/100-b2in-upload-limits.ini:ro'
networks:
- sail
depends_on:
diff --git a/.devcontainer/php-upload-limits.ini b/.devcontainer/php-upload-limits.ini
index ed99be6..4a13806 100644
--- a/.devcontainer/php-upload-limits.ini
+++ b/.devcontainer/php-upload-limits.ini
@@ -1,2 +1,4 @@
+[PHP]
+; Muss über Sail-Standard (99-sail.ini: 100M) liegen; siehe Display-Mediathek / Livewire-Uploads (~200 MB).
upload_max_filesize = 210M
post_max_size = 210M
diff --git a/.env.example b/.env.example
index ed229cd..4ffd7b0 100644
--- a/.env.example
+++ b/.env.example
@@ -64,6 +64,12 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
+# Display-Player URL (Basis-URL der Player-Seite ohne ?id=).
+# Live: https://cabinet.b2in.eu/display
+# Lokal: https://portal.b2in.test/_cabinet/display (Player-Seite + API laufen auf der Portal-Domain)
+# Ohne Wert greift der Produktions-Fallback aus config/display.php.
+DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display
+
# Cookie Consent & Google Analytics (acme/cookie-consent)
# GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX
# GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXXX
diff --git a/app/Console/Commands/GenerateVideoThumbnails.php b/app/Console/Commands/GenerateVideoThumbnails.php
new file mode 100644
index 0000000..e325da0
--- /dev/null
+++ b/app/Console/Commands/GenerateVideoThumbnails.php
@@ -0,0 +1,59 @@
+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(" ✓ {$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;
+ }
+}
diff --git a/app/Console/Commands/MigrateLegacyDisplays.php b/app/Console/Commands/MigrateLegacyDisplays.php
index 2ea2dc0..4ddcb64 100644
--- a/app/Console/Commands/MigrateLegacyDisplays.php
+++ b/app/Console/Commands/MigrateLegacyDisplays.php
@@ -4,6 +4,8 @@ namespace App\Console\Commands;
use App\Models\Display;
use App\Models\DisplayFooterContent;
+use App\Models\DisplayPlaylist;
+use App\Models\DisplayPlaylistItem;
use App\Models\DisplayVersion;
use App\Models\DisplayVersionItem;
use App\Models\DisplayVideo;
@@ -75,7 +77,16 @@ class MigrateLegacyDisplays extends Command
'is_active' => true,
]);
- $display->versions()->attach($version->id, ['sort_order' => 0]);
+ $playlist = $display->playlists()->create([
+ 'status' => DisplayPlaylist::STATUS_PUBLISHED,
+ 'published_at' => now(),
+ ]);
+
+ DisplayPlaylistItem::create([
+ 'display_playlist_id' => $playlist->id,
+ 'display_version_id' => $version->id,
+ 'sort_order' => 0,
+ ]);
$this->info("Migrated {$videos->count()} videos and {$footers->count()} footer items.");
$this->info("Created version: {$version->name} (ID: {$version->id})");
diff --git a/app/Http/Controllers/Api/DisplayPreviewController.php b/app/Http/Controllers/Api/DisplayPreviewController.php
index 05cf830..a907c8d 100644
--- a/app/Http/Controllers/Api/DisplayPreviewController.php
+++ b/app/Http/Controllers/Api/DisplayPreviewController.php
@@ -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
diff --git a/app/Http/Controllers/Api/DisplayVersionApiController.php b/app/Http/Controllers/Api/DisplayVersionApiController.php
index 37ca1ac..b101124 100644
--- a/app/Http/Controllers/Api/DisplayVersionApiController.php
+++ b/app/Http/Controllers/Api/DisplayVersionApiController.php
@@ -9,6 +9,35 @@ use Illuminate\Http\JsonResponse;
class DisplayVersionApiController extends Controller
{
+ public function overview(): JsonResponse
+ {
+ $displays = Display::query()
+ ->with(['livePlaylist.modules'])
+ ->where('is_active', true)
+ ->whereHas('livePlaylist.modules')
+ ->orderBy('name')
+ ->get()
+ ->map(function (Display $display): array {
+ $playlist = $display->livePlaylist;
+
+ return [
+ 'id' => $display->id,
+ 'name' => $display->name,
+ 'location' => $display->location,
+ 'is_active' => $display->is_active,
+ 'is_live' => true,
+ 'module_count' => $playlist?->modules->count() ?? 0,
+ 'updated_at' => $playlist?->updated_at?->toIso8601String(),
+ 'url' => rtrim(config('display.player_url'), '/').'/?id='.$display->id,
+ ];
+ })
+ ->values();
+
+ return response()->json([
+ 'displays' => $displays,
+ ]);
+ }
+
public function config(Display $display, DisplayPlaylistConfigBuilder $configBuilder): JsonResponse
{
if (! $display->is_active) {
diff --git a/app/Http/Controllers/Api/ModulePreviewController.php b/app/Http/Controllers/Api/ModulePreviewController.php
index 560184a..bec62e4 100644
--- a/app/Http/Controllers/Api/ModulePreviewController.php
+++ b/app/Http/Controllers/Api/ModulePreviewController.php
@@ -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
diff --git a/app/Livewire/Admin/Cms/DisplayList.php b/app/Livewire/Admin/Cms/DisplayList.php
index dda18eb..698e540 100644
--- a/app/Livewire/Admin/Cms/DisplayList.php
+++ b/app/Livewire/Admin/Cms/DisplayList.php
@@ -30,6 +30,9 @@ class DisplayList extends Component
public $addVersionSelect = null;
+ /** @var array */
+ public $versionsToAdd = [];
+
public $editingPlaylistStatus = DisplayPlaylist::STATUS_PUBLISHED;
public ?string $draftPreviewToken = null;
@@ -44,7 +47,7 @@ class DisplayList extends Component
], true) ? $playlistStatus : DisplayPlaylist::STATUS_PUBLISHED;
if ($id) {
- $display = Display::with(['livePlaylist.modules', 'draftPlaylist.modules', 'versions'])->findOrFail($id);
+ $display = Display::with(['livePlaylist.modules', 'draftPlaylist.modules'])->findOrFail($id);
$this->displayId = $display->id;
$this->displayName = $display->name;
$this->displayLocation = $display->location ?? '';
@@ -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()
@@ -137,7 +154,6 @@ class DisplayList extends Component
$this->previewFrameRefreshCounter++;
} else {
$this->syncPublishedPlaylist($display);
- $this->syncLegacyPivot($display, $this->selectedVersionIds);
}
$this->closeModal();
@@ -176,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);
@@ -202,24 +239,11 @@ class DisplayList extends Component
return $display->draftPlaylist->fresh('modules');
});
- $this->syncLegacyPivot($display, $this->moduleIdsForPlaylist($publishedPlaylist));
+ $display->clearPreviewToken();
session()->flash('success', 'Entwurf wurde veröffentlicht.');
}
- /**
- * @param array $versionIds
- */
- private function syncLegacyPivot(Display $display, array $versionIds): void
- {
- $syncData = [];
- foreach ($versionIds as $sortOrder => $versionId) {
- $syncData[$versionId] = ['sort_order' => $sortOrder];
- }
-
- $display->versions()->sync($syncData);
- }
-
private function syncPublishedPlaylist(Display $display): void
{
$playlist = $display->playlists()->firstOrCreate(
@@ -297,8 +321,7 @@ class DisplayList extends Component
return $this->moduleIdsForPlaylist($display->draftPlaylist);
}
- return $this->moduleIdsForPlaylist($display->livePlaylist)
- ?: $display->versions->pluck('id')->all();
+ return $this->moduleIdsForPlaylist($display->livePlaylist);
}
public function deleteDisplay(int $id): void
@@ -331,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;
diff --git a/app/Livewire/Admin/Cms/DisplayMediaPicker.php b/app/Livewire/Admin/Cms/DisplayMediaPicker.php
index 1c56870..27b2b90 100644
--- a/app/Livewire/Admin/Cms/DisplayMediaPicker.php
+++ b/app/Livewire/Admin/Cms/DisplayMediaPicker.php
@@ -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);
}
diff --git a/app/Livewire/Admin/Cms/DisplayVersionEditor.php b/app/Livewire/Admin/Cms/DisplayVersionEditor.php
index c79d185..ac5a318 100644
--- a/app/Livewire/Admin/Cms/DisplayVersionEditor.php
+++ b/app/Livewire/Admin/Cms/DisplayVersionEditor.php
@@ -3,9 +3,10 @@
namespace App\Livewire\Admin\Cms;
use App\Enums\DisplayVersionType;
+use App\Models\DisplayMedia;
use App\Models\DisplayVersion;
use App\Models\DisplayVersionItem;
-use Illuminate\Support\Facades\File;
+use App\Support\DisplayModuleSettings;
use Livewire\Attributes\On;
use Livewire\Component;
@@ -55,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;
@@ -64,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 */
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 = '';
@@ -98,32 +119,25 @@ class DisplayVersionEditor extends Component
public array $settings = [];
- /** @var array */
- public array $availableVideos = [];
-
public int $previewFrameRefreshCounter = 0;
+ /** @var array */
+ 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();
}
}
@@ -159,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 $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
// ========================================
@@ -275,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
@@ -305,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 $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 $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 $content
+ */
+ private function loadMediaContent(array $content, bool $isActive): void
{
$this->mediaType = $content['media_type'] ?? 'image';
$this->mediaCategory = $content['category'] ?? 'immobilien';
@@ -339,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 $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'] ?? '';
@@ -353,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'] ?? '') !== '';
}
/**
@@ -389,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 => [],
@@ -450,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;
}
@@ -480,46 +595,7 @@ class DisplayVersionEditor extends Component
*/
private function settingsWithDefaults(): array
{
- return array_replace_recursive($this->defaultSettings(), $this->version->settings ?? []);
- }
-
- /**
- * @return array
- */
- private function defaultSettings(): array
- {
- return match ($this->version->type) {
- DisplayVersionType::VideoDisplay => [
- 'qr_label' => 'Website',
- ],
- DisplayVersionType::B2in => [
- 'theme' => 'dark',
- 'header_logo_url' => '../assets/b2in-logo-positive.svg',
- 'header_claim' => 'Connecting Design & Property',
- 'footer_url' => 'B2in.eu',
- 'footer_name' => '',
- 'footer_prefix' => 'by',
- 'qr_url' => '',
- 'transition' => [
- 'type' => 'crossfade',
- 'duration_ms' => 800,
- ],
- 'default_image_duration' => 10,
- ],
- DisplayVersionType::Offers => [
- '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,
- ],
- ],
- };
+ return DisplayModuleSettings::merge($this->version->type, $this->version->settings);
}
public function render()
diff --git a/app/Livewire/Admin/Cms/DisplayVersionList.php b/app/Livewire/Admin/Cms/DisplayVersionList.php
index 4a0ceb4..80dd21e 100644
--- a/app/Livewire/Admin/Cms/DisplayVersionList.php
+++ b/app/Livewire/Admin/Cms/DisplayVersionList.php
@@ -4,6 +4,8 @@ namespace App\Livewire\Admin\Cms;
use App\Enums\DisplayVersionType;
use App\Models\DisplayVersion;
+use App\Support\DisplayModuleSettings;
+use Illuminate\Support\Facades\DB;
use Livewire\Component;
class DisplayVersionList extends Component
@@ -52,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();
@@ -70,40 +85,17 @@ class DisplayVersionList extends Component
*/
private function defaultSettingsForType(string $type): array
{
- return match ($type) {
- 'b2in' => [
- 'theme' => 'dark',
- 'header_logo_url' => '../assets/b2in-logo-positive.svg',
- 'header_claim' => 'Connecting Design & Property',
- 'footer_name' => '',
- 'footer_url' => 'B2in.eu',
- 'footer_prefix' => 'by',
- 'qr_url' => '',
- 'transition' => ['type' => 'crossfade', 'duration_ms' => 800],
- 'default_image_duration' => 10,
- 'rotation_weights' => ['immobilien' => 70, 'moebel' => 30],
- 'display_active' => true,
- ],
- 'offers' => [
- '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],
- ],
- 'video-display' => [
- 'qr_label' => 'Website',
- ],
- default => [],
- };
+ return DisplayModuleSettings::defaults($type);
}
public function render()
{
- $versions = DisplayVersion::withCount(['items', 'displays'])
+ $versions = DisplayVersion::withCount([
+ 'items',
+ '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)')),
+ ])
->orderBy('name')
->get();
diff --git a/app/Livewire/Admin/Cms/MediaLibraryUploader.php b/app/Livewire/Admin/Cms/MediaLibraryUploader.php
index 8b495b6..90c1986 100644
--- a/app/Livewire/Admin/Cms/MediaLibraryUploader.php
+++ b/app/Livewire/Admin/Cms/MediaLibraryUploader.php
@@ -17,7 +17,7 @@ class MediaLibraryUploader extends Component
{
$this->validate([
'uploads' => 'nullable|array|max:20',
- 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
+ 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:204800',
]);
$service = app(MediaConversionService::class);
diff --git a/app/Livewire/Admin/Cms/MediaPicker.php b/app/Livewire/Admin/Cms/MediaPicker.php
index 14eb146..ee4e9af 100644
--- a/app/Livewire/Admin/Cms/MediaPicker.php
+++ b/app/Livewire/Admin/Cms/MediaPicker.php
@@ -73,7 +73,7 @@ class MediaPicker extends Component
{
$this->validate([
'quickUploads' => 'nullable|array|max:5',
- 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
+ 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:204800',
]);
$service = app(MediaConversionService::class);
diff --git a/app/Livewire/Cabinet/QuickStatus.php b/app/Livewire/Cabinet/QuickStatus.php
index 47dcd6a..a97410d 100644
--- a/app/Livewire/Cabinet/QuickStatus.php
+++ b/app/Livewire/Cabinet/QuickStatus.php
@@ -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,
diff --git a/app/Models/Display.php b/app/Models/Display.php
index 279ec51..73d705d 100644
--- a/app/Models/Display.php
+++ b/app/Models/Display.php
@@ -4,7 +4,6 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
-use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Str;
@@ -30,17 +29,6 @@ class Display extends Model
];
}
- /**
- * @deprecated Wird in Phase 7 entfernt. Nutze stattdessen liveModules()
- * oder die Playlist-Relationen (livePlaylist, draftPlaylist).
- */
- public function versions(): BelongsToMany
- {
- return $this->belongsToMany(DisplayVersion::class, 'display_display_version')
- ->withPivot('sort_order')
- ->orderByPivot('sort_order');
- }
-
/**
* @return HasMany
*/
@@ -67,29 +55,6 @@ class Display extends Model
->where('status', DisplayPlaylist::STATUS_DRAFT);
}
- /**
- * Liefert die Module der aktuell veröffentlichten Bespielung in Reihenfolge.
- */
- public function liveModules(): BelongsToMany
- {
- return $this->belongsToMany(
- DisplayVersion::class,
- 'display_playlist_items',
- 'display_playlist_id',
- 'display_version_id'
- )
- ->wherePivotIn(
- 'display_playlist_id',
- DisplayPlaylist::query()
- ->where('display_id', $this->id ?? 0)
- ->where('status', DisplayPlaylist::STATUS_PUBLISHED)
- ->select('id')
- )
- ->withPivot(['sort_order', 'id'])
- ->withTimestamps()
- ->orderByPivot('sort_order');
- }
-
public function ensurePreviewToken(): string
{
if (! $this->preview_token) {
@@ -99,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();
+ }
+ }
}
diff --git a/app/Models/DisplayMedia.php b/app/Models/DisplayMedia.php
index 3cf2397..eb91036 100644
--- a/app/Models/DisplayMedia.php
+++ b/app/Models/DisplayMedia.php
@@ -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}%");
+ });
+ }
}
diff --git a/app/Models/DisplayVersion.php b/app/Models/DisplayVersion.php
index 20cf372..539a805 100644
--- a/app/Models/DisplayVersion.php
+++ b/app/Models/DisplayVersion.php
@@ -6,7 +6,6 @@ use App\Enums\DisplayVersionType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
-use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class DisplayVersion extends Model
@@ -35,10 +34,12 @@ class DisplayVersion extends Model
return $this->hasMany(DisplayVersionItem::class)->orderBy('sort_order');
}
- public function displays(): BelongsToMany
+ /**
+ * @return HasMany
+ */
+ public function playlistItems(): HasMany
{
- return $this->belongsToMany(Display::class, 'display_display_version')
- ->withPivot('sort_order');
+ return $this->hasMany(DisplayPlaylistItem::class);
}
/**
diff --git a/app/Services/DisplayMediaService.php b/app/Services/DisplayMediaService.php
index bc08115..f62c459 100644
--- a/app/Services/DisplayMediaService.php
+++ b/app/Services/DisplayMediaService.php
@@ -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);
}
/**
diff --git a/app/Services/DisplayPlaylistConfigBuilder.php b/app/Services/DisplayPlaylistConfigBuilder.php
index ea05231..46c7414 100644
--- a/app/Services/DisplayPlaylistConfigBuilder.php
+++ b/app/Services/DisplayPlaylistConfigBuilder.php
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\DisplayPlaylist;
use App\Models\DisplayVersion;
+use App\Support\DisplayModuleSettings;
use Illuminate\Database\Eloquent\Collection;
class DisplayPlaylistConfigBuilder
@@ -95,9 +96,7 @@ class DisplayPlaylistConfigBuilder
return [
'type' => 'video-display',
'version_name' => $module->name,
- 'settings' => array_replace([
- 'qr_label' => 'Website',
- ], $module->settings ?? []),
+ 'settings' => DisplayModuleSettings::merge($module->type, $module->settings),
'videoPlaylist' => $videos,
'footerContent' => $footerContent,
];
@@ -133,20 +132,7 @@ class DisplayPlaylistConfigBuilder
return [
'type' => 'b2in',
'version_name' => $module->name,
- 'settings' => array_replace_recursive([
- 'theme' => 'dark',
- 'header_logo_url' => '../assets/b2in-logo-positive.svg',
- 'header_claim' => 'Connecting Design & Property',
- 'footer_url' => 'B2in.eu',
- 'footer_name' => '',
- 'footer_prefix' => 'by',
- 'qr_url' => '',
- 'transition' => [
- 'type' => 'crossfade',
- 'duration_ms' => 800,
- ],
- 'default_image_duration' => 10,
- ], $module->settings ?? []),
+ 'settings' => DisplayModuleSettings::merge($module->type, $module->settings),
'items' => $mediaItems,
];
}
@@ -157,42 +143,45 @@ 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',
'version_name' => $module->name,
- 'settings' => array_replace_recursive([
- '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,
- ],
- ], $module->settings ?? []),
+ 'settings' => DisplayModuleSettings::merge($module->type, $module->settings),
'slides' => $slides,
];
}
diff --git a/app/Support/DisplayModuleSettings.php b/app/Support/DisplayModuleSettings.php
new file mode 100644
index 0000000..2fcad41
--- /dev/null
+++ b/app/Support/DisplayModuleSettings.php
@@ -0,0 +1,60 @@
+
+ */
+ public static function defaults(DisplayVersionType|string $type): array
+ {
+ $typeValue = $type instanceof DisplayVersionType ? $type->value : $type;
+
+ return match ($typeValue) {
+ DisplayVersionType::VideoDisplay->value => [
+ 'qr_label' => 'Website',
+ ],
+ DisplayVersionType::B2in->value => [
+ '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',
+ 'qr_url' => '',
+ 'transition' => [
+ 'type' => 'crossfade',
+ 'duration_ms' => 800,
+ ],
+ 'default_image_duration' => 10,
+ 'rotation_weights' => ['immobilien' => 70, 'moebel' => 30],
+ 'display_active' => true,
+ ],
+ DisplayVersionType::Offers->value => [
+ 'loop' => true,
+ 'transition' => [
+ 'type' => 'fade',
+ 'duration' => 600,
+ ],
+ ],
+ default => [],
+ };
+ }
+
+ /**
+ * @param array|null $settings
+ * @return array
+ */
+ public static function merge(DisplayVersionType|string $type, ?array $settings): array
+ {
+ return array_replace_recursive(self::defaults($type), $settings ?? []);
+ }
+}
diff --git a/config/display.php b/config/display.php
index 9443e45..f1fff65 100644
--- a/config/display.php
+++ b/config/display.php
@@ -22,4 +22,7 @@ return [
// Haupt-Domain
'domain' => env('DISPLAY_DOMAIN', 'b2in.eu'),
+ // Öffentliche Player-URL der Display-Domain
+ 'player_url' => env('DISPLAY_PLAYER_URL', 'https://cabinet.b2in.eu/display'),
+
];
diff --git a/database/migrations/2026_05_13_103600_drop_display_display_version_table.php b/database/migrations/2026_05_13_103600_drop_display_display_version_table.php
new file mode 100644
index 0000000..cfb9b9a
--- /dev/null
+++ b/database/migrations/2026_05_13_103600_drop_display_display_version_table.php
@@ -0,0 +1,26 @@
+id();
+ $table->foreignId('display_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('display_version_id')->constrained()->cascadeOnDelete();
+ $table->integer('sort_order')->default(0);
+ $table->timestamps();
+
+ $table->unique(['display_id', 'display_version_id']);
+ });
+ }
+};
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index 0f83fd4..b40ae54 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -21,5 +21,7 @@ class DatabaseSeeder extends Seeder
'email' => 'kevin.adametz@me.com',
'password' => Hash::make('xunfew-0Jygjy-minnyt'),
]);
+
+ $this->call(TestDisplaySeeder::class);
}
}
diff --git a/database/seeders/DisplayVersionSeeder.php b/database/seeders/DisplayVersionSeeder.php
index d6a3357..4d27410 100644
--- a/database/seeders/DisplayVersionSeeder.php
+++ b/database/seeders/DisplayVersionSeeder.php
@@ -169,7 +169,9 @@ class DisplayVersionSeeder extends Seeder
'qr_url' => 'https://cabinet-bielefeld.de',
'qr_title' => 'Kontakt',
'contact' => "0521 98620100\nTel. oder WhatsApp",
- 'show_brand_text' => true,
+ 'show_logo' => true,
+ 'logo_url' => '../logo-cabinet-300.png',
+ 'brand_text' => 'Bielefeld',
'brand_tagline' => "Planung • Beratung\nLieferung & Montage",
],
[
@@ -188,7 +190,9 @@ class DisplayVersionSeeder extends Seeder
'qr_url' => 'https://cabinet-bielefeld.de',
'qr_title' => 'Reservieren',
'contact' => "0521 98620100\nTel. oder WhatsApp",
- 'show_brand_text' => false,
+ 'show_logo' => true,
+ 'logo_url' => '../logo-cabinet-300.png',
+ 'brand_text' => '',
'brand_tagline' => '',
],
[
@@ -212,7 +216,9 @@ class DisplayVersionSeeder extends Seeder
'qr_url' => 'https://cabinet-bielefeld.de',
'qr_title' => 'Reservieren',
'contact' => "0521 98620100\nTel. oder WhatsApp",
- 'show_brand_text' => false,
+ 'show_logo' => true,
+ 'logo_url' => '../logo-cabinet-300.png',
+ 'brand_text' => '',
'brand_tagline' => '',
],
[
@@ -231,7 +237,9 @@ class DisplayVersionSeeder extends Seeder
'qr_url' => 'https://cabinet-bielefeld.de',
'qr_title' => 'Sichern',
'contact' => "0521 98620100\nTel. oder WhatsApp",
- 'show_brand_text' => false,
+ 'show_logo' => true,
+ 'logo_url' => '../logo-cabinet-300.png',
+ 'brand_text' => '',
'brand_tagline' => '',
],
];
diff --git a/database/seeders/TestDisplaySeeder.php b/database/seeders/TestDisplaySeeder.php
new file mode 100644
index 0000000..8bc6729
--- /dev/null
+++ b/database/seeders/TestDisplaySeeder.php
@@ -0,0 +1,31 @@
+where('is_test', true)->exists()) {
+ $this->command?->info('Test-Display existiert bereits – übersprungen.');
+
+ return;
+ }
+
+ Display::query()->create([
+ 'name' => 'Test-Display',
+ 'location' => 'Vorschau / Test',
+ 'is_active' => true,
+ 'is_test' => true,
+ ]);
+
+ $this->command?->info('Test-Display angelegt.');
+ }
+}
diff --git a/dev/displays-11-05-2026/00-entwicklungskonzept.md b/dev/displays-11-05-2026/00-entwicklungskonzept.md
index a6fdce2..07d7534 100644
--- a/dev/displays-11-05-2026/00-entwicklungskonzept.md
+++ b/dev/displays-11-05-2026/00-entwicklungskonzept.md
@@ -16,8 +16,8 @@ Im Admin-Portal unter `portal.b2in.test/admin/cms/` existiert der Bereich **Stor
|---|---|
| `cms/display-dashboard` | Übersicht / Einstieg |
| `cms/display-media` | Mediathek (eigene Display-Mediathek, getrennt von Flux CMS) |
-| `cms/display-versions` | Inhalts-„Versionen" |
-| `cms/display-versions/{id}/edit` | Editor für eine Version |
+| `cms/display-modules` | Inhalts-Module |
+| `cms/display-modules/{id}/edit` | Editor für ein Modul |
| `cms/displays` | Physische Displays + Playlist-Zuweisung |
| `cms/cabinet-tablet` | Info-Tablet (Öffnungszeiten/Status) |
@@ -25,13 +25,14 @@ Im Admin-Portal unter `portal.b2in.test/admin/cms/` existiert der Bereich **Stor
```
displays (5 Datensätze live)
-└── m:n via display_display_version (sort_order = Playlist-Reihenfolge)
- └── display_versions (5 Datensätze live)
- ├── type: video-display | b2in | offers
- ├── settings: JSON
- └── 1:n display_version_items (17 Datensätze live)
- ├── item_type: video | footer | media | slide
- └── content: JSON
+└── 1:n display_playlists (Live/Entwurf)
+ └── 1:n display_playlist_items (sort_order = Playlist-Reihenfolge)
+ └── display_versions (technisch), fachlich Module
+ ├── type: video-display | b2in | offers
+ ├── settings: JSON
+ └── 1:n display_version_items
+ ├── item_type: video | footer | media | slide
+ └── content: JSON
```
### 1.3 Echte Live-Daten (Stand heute)
@@ -83,7 +84,7 @@ displays (5 Datensätze live)
| Mediathek | **Display-Mediathek** *(unverändert)* | Bilder/Videos für Displays. |
| Info-Tablet | **Info-Tablet** *(unverändert)* | Eingangs-Tablet mit Öffnungszeiten. |
-Routen werden entsprechend umbenannt: `display-versions` → `display-modules`.
+Routen wurden entsprechend umbenannt: `display-versions` → `display-modules`. Die Übergangs-Redirects wurden in Phase 7 entfernt.
### 2.2 Neues mentales Modell
@@ -181,7 +182,7 @@ für jedes Display D:
erstelle display_playlists (display_id=D.id, status='published', published_at=now())
für jeden Eintrag aus display_display_version (display_id=D.id), sortiert nach sort_order:
erstelle display_playlist_items (...)
-display_display_version-Tabelle bleibt vorerst → wird in Phase 7 dropped.
+display_display_version-Tabelle wurde in Phase 7 dropped.
```
**Ergebnis nach Migration:** Alle 5 Displays haben eine Live-Bespielung, kein Entwurf. Konsumenten-API liefert exakt das gleiche wie heute.
@@ -355,15 +356,15 @@ Jede Phase liefert ein in sich getestetes, deploybares Inkrement.
- [ ] Player-Templates: Single-Module-Modus
### Phase 6 – Umbenennung & Onboarding (Tag 3)
-- [ ] Routen: `display-versions` → `display-modules` (mit 301-Redirect)
-- [ ] Komponenten / Views umbenennen
-- [ ] Dashboard-Texte / Hilfe-Bausteine aktualisieren
-- [ ] Tooltips an Schlüsselstellen
+- [x] Routen: `display-versions` → `display-modules`
+- [x] Komponenten / Views umbenennen
+- [x] Dashboard-Texte / Hilfe-Bausteine aktualisieren
+- [x] Tooltips an Schlüsselstellen
### Phase 7 – Aufräumen (Tag 4)
-- [ ] `display_display_version`-Tabelle dropped
-- [ ] Alte Routen entfernt
-- [ ] `DISPLAY_CMS_README.md` aktualisiert (in `dev/` ablegen)
+- [x] `display_display_version`-Tabelle dropped
+- [x] Alte Routen entfernt
+- [x] Entwicklerdoku in `dev/displays-11-05-2026` aktualisiert
- [ ] Vollständiger Test-Run
---
diff --git a/dev/displays-11-05-2026/01-status.md b/dev/displays-11-05-2026/01-status.md
index e29d514..16628d7 100644
--- a/dev/displays-11-05-2026/01-status.md
+++ b/dev/displays-11-05-2026/01-status.md
@@ -13,7 +13,8 @@
| **4** | Admin-UI: Entwurf-Editor (Iframe-Vorschau) | ✅ 12.05.2026 |
| **5** | Modul-Editor: 3-stufige Vorschau | ✅ 12.05.2026 |
| **6** | Umbenennung Versionen → Module + Onboarding | ✅ 12.05.2026 |
-| **7** | Aufräumen + alte Pivot-Tabelle entfernen | ⏳ offen |
+| **7** | Aufräumen + alte Pivot-Tabelle entfernen | ✅ 13.05.2026 |
+| **8** | Review: Fehler / Optimierungen / Erweiterungen | 🟡 29.05.2026 (Befundaufnahme) |
Legende: ✅ fertig · 🟡 in Arbeit · ⏳ offen · ⛔ blockiert
@@ -245,7 +246,7 @@ Umsetzung:
## Phase 6 – Umbenennung Versionen → Module + Onboarding
-**Ziel:** Die Admin-UI verwendet den fachlich korrekten Begriff „Module“. Alte URLs bleiben kompatibel und leiten weiter.
+**Ziel:** Die Admin-UI verwendet den fachlich korrekten Begriff „Module“. Alte URLs wurden während der Übergangsphase per 301 weitergeleitet und in Phase 7 entfernt.
### Stand 12.05.2026 – ✅ abgeschlossen
@@ -261,9 +262,9 @@ Dateien:
Umsetzung:
- Neue Routen: `admin/cms/display-modules` und `admin/cms/display-modules/{displayVersion}/edit`
- Neue Routennamen: `admin.cms.display-modules` und `admin.cms.display-module-edit`
-- Alte `display-versions`-Routen bleiben erhalten und leiten per 301 auf die Modul-Routen weiter
+- Alte `display-versions`-Routen waren während der Übergangsphase als 301-Redirects aktiv und wurden in Phase 7 entfernt
- Sidebar, Dashboard, Listen- und Editor-Texte verwenden „Module“
-- Technische Modell-/Klassennamen bleiben bis Phase 7 kompatibel bei `DisplayVersion`
+- Technische Modell-/Klassennamen bleiben bei `DisplayVersion`, da sie fachlich weiterhin die wiederverwendbaren Module abbilden
#### Tests
@@ -276,3 +277,99 @@ tests/Feature/DisplayPlaylistMigrationTest.php – ok
Insgesamt 64 grüne Tests für Phasen 5/6 und die angrenzenden Display-Flows. Pint clean.
+---
+
+## Phase 7 – Technisches Aufräumen & Optimierung
+
+**Ziel:** Nach Stabilisierung des neuen Playlist-Flows wird die alte Pivot-Kompatibilität entfernt und der Modul-Editor weiter vereinheitlicht.
+
+### Stand 13.05.2026 – ✅ umgesetzt
+
+Dateien:
+- `app/Models/Display.php`
+- `app/Models/DisplayVersion.php`
+- `app/Livewire/Admin/Cms/DisplayList.php`
+- `app/Console/Commands/MigrateLegacyDisplays.php`
+- `app/Support/DisplayModuleSettings.php`
+- `app/Services/DisplayPlaylistConfigBuilder.php`
+- `app/Livewire/Admin/Cms/DisplayVersionEditor.php`
+- `app/Livewire/Admin/Cms/DisplayVersionList.php`
+- `routes/admin.php`
+- `database/migrations/2026_05_13_103600_drop_display_display_version_table.php`
+- `resources/views/livewire/admin/cms/display-list.blade.php`
+- `resources/views/livewire/admin/cms/display-version-editor.blade.php`
+- `resources/views/livewire/admin/cms/partials/version-editor-video.blade.php`
+
+Umsetzung:
+- Alte Pivot-Tabelle `display_display_version` wird per Migration entfernt
+- Legacy-Relationen `Display::versions()` und `DisplayVersion::displays()` wurden entfernt
+- Display-Bearbeitung, Draft-Veröffentlichung und Legacy-Migrations-Command schreiben ausschließlich in `display_playlists` und `display_playlist_items`
+- Alte `display-versions`-Redirect-Routen wurden entfernt; die Admin-UI nutzt nur noch `display-modules`
+- Modul-Settings-Defaults liegen zentral in `App\Support\DisplayModuleSettings` und werden von Editor, Listen-Erstellung und API-Config-Builder gemeinsam genutzt
+- Admin-Iframes laden per `loading="lazy"` verzögert, um die parallelen Player-Vorschauen leichter zu halten
+- Video-Display-Items zeigen im Editor sichtbar an, ob die Quelle aus der Mediathek oder aus einem Legacy-Dateinamen kommt
+
+---
+
+## Phase 8 – Review 29.05.2026 (Befundaufnahme)
+
+Geprüft wurden die drei Navigationspunkte:
+- `admin/cms/display-media` → Volt `admin.cms.display-media-library`
+- `admin/cms/display-modules` → `App\Livewire\Admin\Cms\DisplayVersionList` (+ Editor `DisplayVersionEditor`)
+- `admin/cms/displays` → `App\Livewire\Admin\Cms\DisplayList`
+
+**Vorgehen:** Code-Review der Komponenten/Views/Services/Player + Laravel-Logs + DB-Stand. Eine Browser-Sichtprüfung war in dieser Session nicht möglich (Browser-MCP nicht verfügbar). **Alle Befunde (#1–#11) wurden inzwischen umgesetzt** (Details je Punkt unten).
+
+**Aktueller Datenstand (DB):** 5 Displays (davon **0** Test-Displays), 6 Module, 7 Playlists (5 Live + 2 Entwürfe), 13 Medien.
+
+**Tests:** `DisplayListTest`, `DisplayMediaTest`, `DisplayVersionTest`, `DisplayVersionApiTest`, `DisplayPlaylistMigrationTest` → **95 passed** (276 Assertions). Keine CMS-Fehler in `storage/logs` (vorhandene Log-Fehler betreffen unrelated `immobilien-azizi`-Routen).
+
+### 🔴 Fehler (sollten behoben werden)
+
+1. **Mediathek-Suche hebelt alle Filter aus.** ✅ **behoben 29.05.2026**
+ In `display-media-library.blade.php` (`$media`-Computed) und in `DisplayMediaPicker::resolveMediaItems()` wurde die Suche als `->where('filename','like',…)->orWhere('title','like',…)` ohne Gruppierung an die vorherigen `when()`-Filter gehängt. Durch SQL-Präzedenz (`AND` bindet stärker als `OR`) wurde die Query zu `… AND filename LIKE … OR title LIKE …`. Sobald gesucht wurde, wurden **Typ-, Quelle-, Sammlungs- und (beim Picker) der `active()`-Filter ignoriert**, sobald ein Treffer über `title` zustande kam.
+ *Behebung:* Neuer gekapselter Scope `DisplayMedia::scopeSearch()` (Closure um `filename`/`title`), den Library und Picker gemeinsam nutzen. Tests: `keeps preceding filters when combined with the search scope`, `search scope respects the active filter on the media picker` in `DisplayMediaTest.php`.
+
+2. **Inhalt-Bearbeiten reaktiviert deaktivierte Items.** ✅ **behoben 29.05.2026**
+ In `DisplayVersionEditor` übergab `loadItemContent()` nur das `content`-Array an `loadVideoContent()/loadFooterContent()/loadMediaContent()/loadSlideContent()`. Alle vier setzten `…IsActive = true` fest – das tatsächliche `is_active` des Items wurde nie geladen. Wer ein zuvor per Auge-Icon deaktiviertes Item öffnete und „Aktualisieren" klickte, hat es über `getActiveFlag()` **ungewollt wieder aktiviert**.
+ *Behebung:* `loadItemContent()` reicht `(bool) $item->is_active` an die Loader durch, die es in die jeweilige `…IsActive`-Property schreiben. Test: `editing an inactive item keeps it inactive` in `DisplayVersionTest.php`.
+
+### 🟡 Optimierungen / Aufräumen
+
+3. **Toter Code im Video-Editor.** ✅ **behoben 29.05.2026** `DisplayVersionEditor::loadAvailableVideos()`, Property `$availableVideos`, der `mount()`-Aufruf und der ungenutzte `Illuminate\Support\Facades\File`-Import wurden entfernt (im aktiven Editor-Blade ungenutzt; das einzige Blade mit `availableVideos` gehört zur Legacy-`CabinetDisplay`). Spart den Dateisystem-Scan von `public/_cabinet/assets` bei jedem Editor-Aufruf.
+
+4. **Modul-Löschen ohne Schutz.** ✅ **behoben 29.05.2026** `DisplayVersionList::deleteVersion()` ermittelt jetzt den `displays_count` (distinct Displays über Playlists) und **blockiert** das Löschen, solange das Modul in irgendeiner Bespielung (Live oder Entwurf) genutzt wird; es erscheint ein roter Flash-Hinweis mit Anzahl. Module ohne Nutzung lassen sich weiterhin löschen. Test: `cannot delete a display version that is used by a playlist` in `DisplayVersionTest.php`.
+
+5. **Doppelte Such-Logik** zwischen Library und Picker (siehe #1) → ein gemeinsamer Query-Scope `DisplayMedia::scopeSearch(string $term)` verhindert künftige Divergenz. ✅ **erledigt 29.05.2026** (im Zuge von #1).
+
+6. **Preview-Token bleibt nach Veröffentlichen bestehen.** ✅ **behoben 29.05.2026**
+ `DisplayList::publishDraft()`/`discardDraft()` ließen `displays.preview_token` gesetzt, obwohl `/preview/{token}` danach 404 lieferte (kein Draft mehr); ein Rotations-Button fehlte ganz.
+ *Behebung:* Neue Model-Methoden `Display::rotatePreviewToken()` und `Display::clearPreviewToken()`. `publishDraft()` und `discardDraft()` setzen den Token jetzt zurück (alter Link wird ungültig). Im Entwurf gibt es einen „Link erneuern"-Button (`rotatePreviewToken()` mit `wire:confirm`), der bei offenem Entwurf zugleich die Iframe-Vorschau aktualisiert. Tests: `publishing a draft clears the preview token`, `can discard a draft playlist` (erweitert) und `can rotate the preview token of a draft` in `DisplayListTest.php`.
+
+### 🟠 Konfiguration / Dev-Umgebung
+
+7. **Live-Vorschau zeigt im lokalen Dev auf die Produktion.** ✅ **behoben 29.05.2026**
+ `config/display.php` `player_url` defaultete auf `https://cabinet.b2in.eu/display`, und `DISPLAY_PLAYER_URL` war in `.env` nicht gesetzt. In `display-list.blade.php` nutzten „Vorschau", „Live-URL zum Kopieren" und „Display-Übersicht öffnen" diese Produktions-URL, während die Entwurfs-„Test-URL" über `url('/preview/…')` lokal lief. Lokal sah man Live also den Produktionsstand, Entwurf aber lokale Daten – inkonsistent und irreführend.
+ *Behebung:* Player-Seite (`public/_cabinet/display/`) **und** die Display-API liegen auf der Portal-Domain, daher ist der Player lokal unter `https://portal.b2in.test/_cabinet/display` erreichbar (der Player ermittelt `BASE_URL` aus `window.location.origin` und ruft die API auf derselben Domain auf).
+ - `.env`: `DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display` gesetzt (Config-Cache geleert).
+ - `.env.example`: dokumentiert (Live-Wert als Kommentar).
+ - `phpunit.xml`: `DISPLAY_PLAYER_URL=https://cabinet.b2in.eu/display` gepinnt, damit die bestehenden URL-Tests (`DisplayListTest`, `DisplayVersionApiTest`) deterministisch gegen den Produktionswert prüfen, unabhängig von der lokalen `.env`.
+ - Der Produktions-Fallback in `config/display.php` (und damit das Live-Verhalten) bleibt unverändert.
+
+### 🟢 Erweiterungen / Verbesserungen (UX)
+
+8. **Test-Display existiert nicht.** ✅ **behoben 29.05.2026** Neuer idempotenter `Database\Seeders\TestDisplaySeeder` legt genau **ein** Test-Display an (überspringt, falls schon vorhanden) und ist im `DatabaseSeeder` registriert. In der Dev-DB wurde das Test-Display angelegt. Tests: `seeds exactly one test display`, `is idempotent and does not create a second test display` in `TestDisplaySeederTest.php`.
+
+9. **Externe Bilder ohne Thumbnail.** ✅ **behoben 29.05.2026** Grid, Listenansicht und Detail-Sidebar (`display-media-library.blade.php`) rendern externe Bilder jetzt direkt via `` (gemeinsame `$thumbSrc`-Logik: Upload-Thumbnail → sonst `external_url`). Nicht-Bild-Externe behalten das Link-Icon. Test: `renders external image media with an inline thumbnail` in `DisplayMediaTest.php`.
+
+10. **Mediathek-Pagination ohne `resetPage`.** ✅ **behoben 29.05.2026** Die Volt-Komponente nutzt jetzt `WithPagination` und Update-Hooks (`updatedSearch/updatedFilterType/updatedFilterSource/updatedFilterCollection`), die bei jeder Filter-/Suchänderung `resetPage()` aufrufen. Test: `resets pagination to page one when a filter changes` in `DisplayMediaTest.php`.
+
+11. **Modul-Hinzufügen nur einzeln.** ✅ **behoben 29.05.2026** Der Bespielungs-Dialog (`display-list.blade.php`) nutzt jetzt einen durchsuchbaren Flux-Listbox-Multi-Select (`versionsToAdd`) plus „Hinzufügen"-Button (`addSelectedVersions()`), der alle ausgewählten Module auf einmal übernimmt (Duplikate werden übersprungen). Die programmatische `addVersion()`-Methode bleibt für Einzel-Adds/Tests erhalten. Test: `can add multiple modules to a playlist at once` in `DisplayListTest.php`.
+
+### Empfohlene Reihenfolge
+
+1. #1 + #2 (echte Fehler, klein, mit Tests) → 2. #7 (Dev-Konfiguration, blockiert lokales Testen) → 3. #3/#4/#5/#6 (Aufräumen/Robustheit) → 4. #8–#11 (UX-Erweiterungen). Jeder Punkt wird gemäß Projekt-Regeln mit Test abgesichert und mit Pint formatiert.
+
+**Stand 29.05.2026:** Alle Befunde (#1–#11) sind umgesetzt, getestet und mit Pint formatiert.
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
index e3a8b9e..f77ab25 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,7 +32,7 @@ services:
REDIS_HOST: global-redis
volumes:
- '.:/var/www/html'
- - './.devcontainer/php-upload-limits.ini:/etc/php/8.4/cli/conf.d/99-upload-limits.ini:ro'
+ - './.devcontainer/php-upload-limits.ini:/etc/php/8.4/cli/conf.d/100-b2in-upload-limits.ini:ro'
networks:
- sail
- proxy
diff --git a/packages/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php b/packages/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php
index f571e05..4924e6f 100644
--- a/packages/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php
+++ b/packages/flux-cms/core/resources/views/admin-reference/cms/media-library-uploader.blade.php
@@ -3,7 +3,7 @@
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png">
diff --git a/packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php b/packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php
index 8b495b6..90c1986 100644
--- a/packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php
+++ b/packages/flux-cms/core/src/Helpers/MediaLibraryUploader.php
@@ -17,7 +17,7 @@ class MediaLibraryUploader extends Component
{
$this->validate([
'uploads' => 'nullable|array|max:20',
- 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
+ 'uploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:204800',
]);
$service = app(MediaConversionService::class);
diff --git a/packages/flux-cms/core/src/Helpers/MediaPicker.php b/packages/flux-cms/core/src/Helpers/MediaPicker.php
index 14eb146..ee4e9af 100644
--- a/packages/flux-cms/core/src/Helpers/MediaPicker.php
+++ b/packages/flux-cms/core/src/Helpers/MediaPicker.php
@@ -73,7 +73,7 @@ class MediaPicker extends Component
{
$this->validate([
'quickUploads' => 'nullable|array|max:5',
- 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:10240',
+ 'quickUploads.*' => 'file|mimes:jpeg,jpg,png,gif,webp,svg,pdf,doc,docx|max:204800',
]);
$service = app(MediaConversionService::class);
diff --git a/packages/flux-cms/core/src/Helpers/MediaUploader.php b/packages/flux-cms/core/src/Helpers/MediaUploader.php
index c06737d..059cdd0 100644
--- a/packages/flux-cms/core/src/Helpers/MediaUploader.php
+++ b/packages/flux-cms/core/src/Helpers/MediaUploader.php
@@ -18,7 +18,7 @@ class MediaUploader extends Component
public string $directory = 'cms/uploads';
- #[Validate('file|max:10240')]
+ #[Validate('file|max:204800')]
public $file;
public function updatedFile(): void
diff --git a/phpunit.xml b/phpunit.xml
index 5cf230b..4dbc029 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -20,6 +20,7 @@
+
diff --git a/public/_cabinet/_docs/QUICK_START.md b/public/_cabinet/_docs/QUICK_START.md
index 69a1f80..3d2f744 100644
--- a/public/_cabinet/_docs/QUICK_START.md
+++ b/public/_cabinet/_docs/QUICK_START.md
@@ -85,11 +85,11 @@ Beim Hochladen neuer Videos beachten:
- [ ] Format: **MP4** (H.264 + AAC)
- [ ] Auflösung: **Max 1920x1080**
- [ ] Bitrate: **5-10 Mbps**
-- [ ] Dateigröße: **Max 100 MB**
+- [ ] Dateigröße: **Max 200 MB**
- [ ] Länge: **15-60 Sekunden** (optimal)
### ⚠️ Vermeiden:
-- ❌ Zu große Dateien (>100MB)
+- ❌ Zu große Dateien (>200MB)
- ❌ Zu hohe Bitrate (>10 Mbps)
- ❌ Zu lange Videos (>3 Min)
- ❌ Exotische Formate (MOV, AVI, WMV)
diff --git a/public/_cabinet/_docs/VIDEO_OPTIMIZATION_README.md b/public/_cabinet/_docs/VIDEO_OPTIMIZATION_README.md
index a8b86d3..7e69043 100644
--- a/public/_cabinet/_docs/VIDEO_OPTIMIZATION_README.md
+++ b/public/_cabinet/_docs/VIDEO_OPTIMIZATION_README.md
@@ -214,7 +214,7 @@ setTimeout(() => {
### 3. **Dateigrößen**
- **Optimal:** 10-50 MB pro Video
-- **Maximum:** 100 MB pro Video
+- **Maximum:** 200 MB pro Video
- **Warum:** Schnelleres Laden, weniger Buffering
### 4. **Playlist-Größe**
diff --git a/public/_cabinet/display/index.html b/public/_cabinet/display/index.html
index 4873654..c166923 100644
--- a/public/_cabinet/display/index.html
+++ b/public/_cabinet/display/index.html
@@ -143,6 +143,24 @@
text-transform: uppercase; color: rgba(255,255,255,0.7);
}
+ /* B2in Brand (positionable logo + claim) */
+ .b2in-brand { position: absolute; z-index: 12; display: flex; align-items: center; max-width: 60%; }
+ .b2in-brand-logo img { height: 3.5vh; display: block; filter: drop-shadow(0 1px 4px rgba(0,0,0,0.45)); }
+ .b2in-brand-claim {
+ font-size: 1.3vh; font-weight: 300; letter-spacing: 0.15em;
+ text-transform: uppercase; color: rgba(255,255,255,0.85);
+ text-shadow: 0 1px 4px rgba(0,0,0,0.55);
+ }
+ .b2in-brand.pos-top-left { top: 2.5vh; left: 3vh; }
+ .b2in-brand.pos-top-right { top: 2.5vh; right: 3vh; }
+ .b2in-brand.pos-bottom-left { bottom: 2.5vh; left: 3vh; }
+ .b2in-brand.pos-bottom-right { bottom: 2.5vh; right: 3vh; }
+
+ /* Legibility scrims behind positioned brand elements */
+ .b2in-scrim { position: absolute; left: 0; right: 0; height: 12vh; z-index: 11; pointer-events: none; }
+ .b2in-scrim-top { top: 0; background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent); }
+ .b2in-scrim-bottom { bottom: 0; background: linear-gradient(to top, rgba(0,0,0,0.6), transparent); }
+
/* B2in Media */
.b2in-media {
flex: 1; position: relative; overflow: hidden;
@@ -171,6 +189,8 @@
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 {
@@ -204,6 +224,9 @@
background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent);
}
.b2in-layer[data-theme="light"] .b2in-claim { color: rgba(43,63,81,0.6); }
+ .b2in-layer[data-theme="light"] .b2in-brand-claim { color: rgba(43,63,81,0.75); text-shadow: 0 1px 3px rgba(255,255,255,0.5); }
+ .b2in-layer[data-theme="light"] .b2in-scrim-top { background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent); }
+ .b2in-layer[data-theme="light"] .b2in-scrim-bottom { background: linear-gradient(to top, rgba(247,248,250,0.9), transparent); }
.b2in-layer[data-theme="light"] .b2in-text {
background: linear-gradient(to top, rgba(247,248,250,0.85) 40%, transparent);
}
@@ -325,6 +348,10 @@
font-size: 24px; color: #737373; text-align: right;
line-height: 1.35; font-weight: 400;
}
+ .offer-price-note.strike {
+ color: #dc2626; text-decoration: line-through;
+ text-decoration-color: #dc2626; text-decoration-thickness: 3px;
+ }
/* Bullets */
.offer-bullets {
@@ -411,6 +438,66 @@
.status-message { font-weight: 300; opacity: 0.7; }
.status-error { color: #ef4444; font-weight: 500; }
.status-sub { font-size: 1.4vh; opacity: 0.4; margin-top: 1vh; }
+
+ .display-empty {
+ position: absolute; inset: 0; z-index: 10;
+ display: flex; flex-direction: column;
+ align-items: center; justify-content: center;
+ text-align: center; padding: 6vw;
+ background: #000; color: #fff;
+ }
+ .display-empty__title { font-size: 2.4vh; font-weight: 600; }
+ .display-empty__hint { font-size: 1.6vh; opacity: 0.5; margin-top: 1.2vh; font-weight: 300; }
+
+ .display-overview {
+ position: fixed; inset: 0; z-index: 10000;
+ overflow-y: auto; background: radial-gradient(circle at top, #12364d 0, #05070a 42%, #000 100%);
+ color: #fff; cursor: auto; padding: clamp(24px, 5vw, 72px);
+ }
+ .display-overview.hidden { display: none; }
+ .display-overview__inner { width: min(1120px, 100%); margin: 0 auto; }
+ .display-overview__eyebrow {
+ color: #38bdf8; font-size: 13px; font-weight: 700;
+ letter-spacing: 0.16em; text-transform: uppercase; margin-bottom: 12px;
+ }
+ .display-overview h1 {
+ font-size: clamp(34px, 6vw, 76px); line-height: 0.95;
+ letter-spacing: -0.05em; margin-bottom: 18px;
+ }
+ .display-overview__intro {
+ max-width: 720px; color: rgba(255,255,255,0.68);
+ font-size: clamp(16px, 2vw, 22px); line-height: 1.5; margin-bottom: 36px;
+ }
+ .display-overview__grid {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 18px;
+ }
+ .display-card {
+ display: flex; flex-direction: column; gap: 16px;
+ min-height: 220px; padding: 24px; border-radius: 28px;
+ border: 1px solid rgba(255,255,255,0.14);
+ background: rgba(255,255,255,0.08); color: #fff; text-decoration: none;
+ box-shadow: 0 24px 70px rgba(0,0,0,0.24);
+ transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
+ }
+ .display-card:hover {
+ transform: translateY(-2px);
+ border-color: rgba(56,189,248,0.55);
+ background: rgba(255,255,255,0.12);
+ }
+ .display-card__badges { display: flex; flex-wrap: wrap; gap: 8px; }
+ .display-badge {
+ border-radius: 999px; padding: 6px 10px; font-size: 12px; font-weight: 700;
+ background: rgba(34,197,94,0.18); color: #86efac; border: 1px solid rgba(134,239,172,0.28);
+ }
+ .display-badge--live { background: rgba(56,189,248,0.18); color: #7dd3fc; border-color: rgba(125,211,252,0.28); }
+ .display-card__title { font-size: 28px; font-weight: 700; letter-spacing: -0.03em; }
+ .display-card__meta { display: grid; gap: 6px; color: rgba(255,255,255,0.62); font-size: 15px; }
+ .display-card__action { margin-top: auto; color: #7dd3fc; font-weight: 700; }
+ .display-overview__empty {
+ border: 1px dashed rgba(255,255,255,0.24); border-radius: 28px;
+ padding: 32px; color: rgba(255,255,255,0.62);
+ }
@@ -434,6 +521,18 @@
Neustart in Kürze...
+
+
+
Cabinet Display Player
+
Aktive Live-Displays
+
+ Wählen Sie ein Display aus, um die veröffentlichte Live-Bespielung zu öffnen.
+ Angezeigt werden nur aktive Displays mit veröffentlichter Live-Konfiguration.
+