Display Module 13-05-2026

This commit is contained in:
Kevin Adametz 2026-05-13 14:34:08 +02:00
parent 6a65354f4c
commit 9262132325
41 changed files with 496 additions and 334 deletions

View file

@ -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:

View file

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

View file

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

View file

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

View file

@ -44,7 +44,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 ?? '';
@ -137,7 +137,6 @@ class DisplayList extends Component
$this->previewFrameRefreshCounter++;
} else {
$this->syncPublishedPlaylist($display);
$this->syncLegacyPivot($display, $this->selectedVersionIds);
}
$this->closeModal();
@ -202,24 +201,9 @@ class DisplayList extends Component
return $display->draftPlaylist->fresh('modules');
});
$this->syncLegacyPivot($display, $this->moduleIdsForPlaylist($publishedPlaylist));
session()->flash('success', 'Entwurf wurde veröffentlicht.');
}
/**
* @param array<int> $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 +281,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

View file

@ -5,6 +5,7 @@ namespace App\Livewire\Admin\Cms;
use App\Enums\DisplayVersionType;
use App\Models\DisplayVersion;
use App\Models\DisplayVersionItem;
use App\Support\DisplayModuleSettings;
use Illuminate\Support\Facades\File;
use Livewire\Attributes\On;
use Livewire\Component;
@ -480,46 +481,7 @@ class DisplayVersionEditor extends Component
*/
private function settingsWithDefaults(): array
{
return array_replace_recursive($this->defaultSettings(), $this->version->settings ?? []);
}
/**
* @return array<string, mixed>
*/
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()

View file

@ -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
@ -70,40 +72,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();

View file

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

View file

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

View file

@ -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<DisplayPlaylist, $this>
*/
@ -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) {

View file

@ -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<DisplayPlaylistItem, $this>
*/
public function playlistItems(): HasMany
{
return $this->belongsToMany(Display::class, 'display_display_version')
->withPivot('sort_order');
return $this->hasMany(DisplayPlaylistItem::class);
}
/**

View file

@ -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,
];
}
@ -180,19 +166,7 @@ class DisplayPlaylistConfigBuilder
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,
];
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Support;
use App\Enums\DisplayVersionType;
class DisplayModuleSettings
{
/**
* @return array<string, mixed>
*/
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',
'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,
'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,
],
],
default => [],
};
}
/**
* @param array<string, mixed>|null $settings
* @return array<string, mixed>
*/
public static function merge(DisplayVersionType|string $type, ?array $settings): array
{
return array_replace_recursive(self::defaults($type), $settings ?? []);
}
}

View file

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

View file

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::dropIfExists('display_display_version');
}
public function down(): void
{
Schema::create('display_display_version', function (Blueprint $table) {
$table->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']);
});
}
};

View file

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

View file

@ -245,7 +245,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 +261,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 +276,35 @@ 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

View file

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

View file

@ -3,7 +3,7 @@
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png">
<flux:file-upload.dropzone
heading="Dateien hochladen"
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 10 MB pro Datei"
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 200 MB pro Datei"
with-progress />
</flux:file-upload>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -411,6 +411,56 @@
.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-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);
}
</style>
</head>
<body>
@ -434,6 +484,18 @@
<div class="status-sub">Neustart in Kürze...</div>
</div>
<div class="display-overview hidden" id="display-overview">
<div class="display-overview__inner">
<div class="display-overview__eyebrow">Cabinet Display Player</div>
<h1>Aktive Live-Displays</h1>
<p class="display-overview__intro">
Wählen Sie ein Display aus, um die veröffentlichte Live-Bespielung zu öffnen.
Angezeigt werden nur aktive Displays mit veröffentlichter Live-Konfiguration.
</p>
<div class="display-overview__grid" id="display-overview-list"></div>
</div>
</div>
<script>
function escapeHtml(value) {
const div = document.createElement('div');
@ -462,15 +524,12 @@ class DisplayPlayer {
this.moduleId = this.detectModuleId();
this.itemId = this.detectItemId();
this.displayId = this.detectDisplayId();
if (!this.displayId && !this.previewToken && !this.moduleId) {
this.showError('Keine Display-ID oder Vorschau angegeben. URL: /display/index.html?id=1');
return;
}
// API
this.BASE_URL = this.detectBaseUrl();
this.API_CONFIG = this.detectConfigUrl();
this.API_CHECK = this.detectCheckUrl();
this.API_OVERVIEW = `${this.BASE_URL}/api/display/overview`;
// Timing
this.POLL_INTERVAL = 60000;
@ -497,6 +556,8 @@ class DisplayPlayer {
this.loadingInfo = document.getElementById('loading-info');
this.errorOverlay = document.getElementById('error-overlay');
this.errorMessage = document.getElementById('error-message');
this.overviewOverlay = document.getElementById('display-overview');
this.overviewList = document.getElementById('display-overview-list');
this.loadingInfo.textContent = this.detectLoadingLabel();
@ -583,13 +644,16 @@ class DisplayPlayer {
}
return `Modul #${this.moduleId}`;
}
if (!this.displayId) {
return 'Display-Übersicht';
}
return `Display #${this.displayId}`;
}
detectBaseUrl() {
const hostname = window.location.hostname;
if (hostname === 'cabinet.b2in.eu' || hostname.includes('b2in.eu')) {
return 'https://b2in.eu';
if (hostname === 'cabinet.b2in.eu') {
return 'https://portal.b2in.eu';
}
return window.location.origin;
}
@ -602,6 +666,11 @@ class DisplayPlayer {
console.log(`[Display] Initializing ${this.detectLoadingLabel()}`);
try {
if (!this.displayId && !this.previewToken && !this.moduleId) {
await this.fetchOverview();
return;
}
await this.fetchConfig();
if (this.playlist.length === 0) {
@ -642,6 +711,16 @@ class DisplayPlayer {
console.log(`[Display] Loaded ${this.playlist.length} version(s)`);
}
async fetchOverview() {
const response = await fetch(this.API_OVERVIEW);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
this.renderOverview(data.displays || []);
}
startPolling() {
if (!this.API_CHECK) {
return;
@ -779,11 +858,50 @@ class DisplayPlayer {
this.loadingOverlay.classList.add('hidden');
}
renderOverview(displays) {
this.hideLoading();
this.errorOverlay.classList.add('hidden');
this.overviewOverlay.classList.remove('hidden');
if (displays.length === 0) {
this.overviewList.innerHTML = `
<div class="display-overview__empty">
Es sind aktuell keine aktiven Live-Displays veröffentlicht.
</div>
`;
return;
}
this.overviewList.innerHTML = displays.map(display => `
<a class="display-card" href="${this.escapeHtml(display.url)}">
<div class="display-card__badges">
<span class="display-badge">Aktiv</span>
<span class="display-badge display-badge--live">Live</span>
</div>
<div>
<div class="display-card__title">${this.escapeHtml(display.name)}</div>
<div class="display-card__meta">
<span>Display-ID: ${this.escapeHtml(display.id)}</span>
${display.location ? `<span>Standort: ${this.escapeHtml(display.location)}</span>` : ''}
<span>${this.escapeHtml(display.module_count)} Modul(e) veröffentlicht</span>
</div>
</div>
<div class="display-card__action">Display öffnen</div>
</a>
`).join('');
}
showError(msg) {
this.loadingOverlay.classList.add('hidden');
this.errorOverlay.classList.remove('hidden');
this.errorMessage.textContent = msg;
}
escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value ?? '';
return div.innerHTML;
}
}

View file

@ -183,7 +183,7 @@
<flux:navlist.group :heading="__('Cabinet')" class="grid mb-4">
<flux:navlist.group expandable
:expanded="request()->routeIs(['admin.cms.display-dashboard', 'admin.cms.display-media', 'admin.cms.display-modules', 'admin.cms.display-module-edit', 'admin.cms.display-versions', 'admin.cms.display-version-edit', 'admin.cms.displays', 'admin.cms.cabinet', 'admin.cms.cabinet-tablet'])"
:expanded="request()->routeIs(['admin.cms.display-dashboard', 'admin.cms.display-media', 'admin.cms.display-modules', 'admin.cms.display-module-edit', 'admin.cms.displays', 'admin.cms.cabinet', 'admin.cms.cabinet-tablet'])"
heading="Store Displays" class="grid">
<flux:navlist.item icon="squares-2x2" :href="route('admin.cms.display-dashboard')"
:current="request()->routeIs('admin.cms.display-dashboard')" wire:navigate>{{ __('Übersicht') }}
@ -192,7 +192,7 @@
:current="request()->routeIs('admin.cms.display-media')" wire:navigate>{{ __('Mediathek') }}
</flux:navlist.item>
<flux:navlist.item icon="rectangle-group" :href="route('admin.cms.display-modules')"
:current="request()->routeIs(['admin.cms.display-modules', 'admin.cms.display-module-edit', 'admin.cms.display-versions', 'admin.cms.display-version-edit'])" wire:navigate>{{ __('Module') }}
:current="request()->routeIs(['admin.cms.display-modules', 'admin.cms.display-module-edit'])" wire:navigate>{{ __('Module') }}
</flux:navlist.item>
<flux:navlist.item icon="tv" :href="route('admin.cms.displays')"
:current="request()->routeIs('admin.cms.displays')" wire:navigate>{{ __('Displays') }}

View file

@ -171,9 +171,9 @@ $tabletStatus = computed(function () {
Es besteht aus drei Bereichen, die Sie über die Kacheln oben erreichen:
</p>
<ul class="mt-2 ml-5 list-disc space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Mediathek</strong> - Zentrale Verwaltung aller Bilder und Videos fuer die Displays. Dateien bis 200 MB direkt hochladen oder groessere Videos als externe URL (Google Drive, OneDrive) einbinden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Module</strong> Wiederverwendbare Content-Pakete, die auf den Displays abgespielt werden. Jede Modul hat einen bestimmten Typ und enthält passende Inhalte (Videos, Bilder oder Angebots-Slides).</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Displays</strong> Die physischen Bildschirme im Showroom. Jedem Display werden eine oder mehrere Module als Playlist zugewiesen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Mediathek</strong> - Zentrale Verwaltung aller Bilder, SVG-Logos und Videos fuer die Displays. Dateien bis 200 MB direkt hochladen oder groessere Videos als externe URL (Google Drive, OneDrive) einbinden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Module</strong> Wiederverwendbare Content-Pakete, die auf den Displays abgespielt werden. Jedes Modul hat einen bestimmten Typ, passende Inhalte und eigene Meta-Einstellungen fuer Logo, Claim, Footer, QR-Code oder Theme.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Displays</strong> Die physischen Bildschirme im Showroom. Pro Display gibt es einen Live-Stand und optional einen Entwurf, der separat vorbereitet, getestet und bewusst veröffentlicht wird.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Info-Tablet</strong> Das Tablet an der Eingangstür des Showrooms. Hier verwalten Sie Öffnungszeiten, den aktuellen Store-Status und Hinweise für Besucher.</li>
</ul>
</div>
@ -190,10 +190,10 @@ $tabletStatus = computed(function () {
Sie ist unabhängig von der Website-Mediathek (Flux CMS) und speziell auf die Anforderungen der Displays zugeschnitten.
</p>
<ul class="mt-2 ml-5 list-disc space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Direkt-Upload:</strong> Bilder und Videos bis 200 MB direkt per Drag-and-drop oder Dateiauswahl hochladen. Die Dateien werden auf dem Server gespeichert und stehen sofort zur Verfügung.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Direkt-Upload:</strong> Bilder, SVG-Dateien und Videos bis 200 MB direkt per Drag-and-drop oder Dateiauswahl hochladen. Die Dateien werden auf dem Server gespeichert und stehen sofort zur Verfügung.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Externe URLs:</strong> Für Videos über 200 MB (z.&nbsp;B. 4K-Showroom-Rundgänge) können Sie einen Freigabe-Link von Google Drive, OneDrive oder anderen Cloud-Diensten hinterlegen. Diese URL wird wie ein normales Medium in der Mediathek verwaltet und kann genauso in Module eingebunden werden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Sammlungen:</strong> Ordnen Sie Medien in Sammlungen wie <em>immobilien</em>, <em>moebel</em> oder <em>brand</em>, um bei vielen Dateien den Überblick zu behalten.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medienauswahl im Editor:</strong> Beim Bearbeiten eines Moduls erscheint ein „Aus Mediathek"-Button. Darüber öffnen Sie die Medienauswahl und können bestehende Medien wählen oder direkt neue hochladen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medienauswahl im Editor:</strong> Beim Bearbeiten eines Moduls erscheint ein „Aus Mediathek"-Button. Darüber öffnen Sie die Medienauswahl und können bestehende Medien wählen oder direkt neue Dateien inklusive SVG-Logos hochladen.</li>
</ul>
</div>
@ -211,19 +211,19 @@ $tabletStatus = computed(function () {
<ul class="mt-2 ml-5 list-disc space-y-1">
<li>
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Video-Display</strong>
Für Video-Playlists mit optionalem Footer. Inhalte: <em>Videos</em> (Dateiname, Titel, Position/Ausschnitt) und <em>Footer-Zeilen</em> (Überschrift, Unterzeile, optionaler QR-Code-Link).
Für Video-Playlists mit optionalem Footer. Inhalte: <em>Videos</em> aus der Mediathek oder Legacy-Dateinamen, Position/Ausschnitt und <em>Footer-Zeilen</em> (Überschrift, Unterzeile, optionaler QR-Code-Link). Mediathek-URLs wie <code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-1 py-0.5 rounded">/storage/...</code> werden direkt abgespielt.
</li>
<li>
<strong class="font-medium text-zinc-800 dark:text-zinc-200">B2in Display</strong>
Für Medien-Rotation (Bilder und Videos) im Marken-Design. Inhalte: <em>Media-Items</em> mit Kategorie (Immobilien / Möbel), Überschrift, Unterzeile und Anzeigedauer. Unterstützt Light-/Dark-Theme.
Für Medien-Rotation (Bilder und Videos) im Marken-Design. Inhalte: <em>Media-Items</em> mit Kategorie (Immobilien / Möbel), Überschrift, Unterzeile und Anzeigedauer. Unterstützt Light-/Dark-Theme sowie zentrale Meta-Einstellungen für Header-Logo, Claim, Footer-Domain und QR-Code.
</li>
<li>
<strong class="font-medium text-zinc-800 dark:text-zinc-200">Angebote</strong>
Für Produkt-Slides im Angebotsformat. Verschiedene Slide-Layouts: Intro, Produkt-Hero, Produkt-Details und Impuls-Slides mit Preisen, Badges und QR-Codes.
Für Produkt-Slides im Angebotsformat. Verschiedene Slide-Layouts: Intro, Produkt-Hero, Produkt-Details und Impuls-Slides mit Preisen, Badges und QR-Codes. Logo, Brand-Text, Footer-Claim und Web-/QR-URL werden einmal am Modul gepflegt und automatisch von allen Slides übernommen.
</li>
</ul>
<p class="mt-2">
Innerhalb eines Moduls können Sie beliebig viele Inhalte anlegen, die Reihenfolge per Hoch/Runter-Sortierung festlegen und einzelne Einträge aktivieren oder deaktivieren.
Innerhalb eines Moduls können Sie beliebig viele Inhalte anlegen, die Reihenfolge per Hoch/Runter-Sortierung festlegen und einzelne Einträge aktivieren oder deaktivieren. Der Modul-Editor zeigt Inline-Vorschaubilder, eine 9:16-Player-Vorschau und eine Vollbild-Vorschau. Im Slide-Bearbeiten-Dialog wird nur der aktuell bearbeitete Slide als Einzel-Vorschau gerendert.
</p>
</div>
@ -238,9 +238,11 @@ $tabletStatus = computed(function () {
Ein <strong class="font-medium text-zinc-800 dark:text-zinc-200">Display</strong> repräsentiert einen physischen Bildschirm im Showroom.
</p>
<ul class="mt-2 ml-5 list-disc space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Modul-Zuweisung:</strong> Jedem Display können Sie eine oder mehrere Module zuordnen. Die Module werden in der festgelegten Reihenfolge als Playlist abgespielt.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Live und Entwurf:</strong> Jedes Display zeigt den veröffentlichten Live-Stand und optional einen Entwurf. Entwürfe können aus Live angelegt, separat bearbeitet, verworfen oder veröffentlicht werden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Modul-Zuweisung:</strong> Jedem Live- oder Entwurfsstand können Sie eine oder mehrere Module zuordnen. Die Module werden in der festgelegten Reihenfolge als Playlist abgespielt.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Vorschau:</strong> Live- und Entwurfs-URLs sind direkt kopierbar. Entwürfe und Module können zusätzlich im 9:16-Iframe oder im Vollbild geprüft werden.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Aktiv/Inaktiv:</strong> Über den Aktiv-Status können Sie einzelne Displays vorübergehend deaktivieren, ohne die Konfiguration zu verlieren.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">API-Anbindung:</strong> Jedes Display ruft seine Inhalte über eine JSON-API ab (<code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-1 py-0.5 rounded">/api/display/{id}/config</code>). Änderungen werden beim nächsten Abruf automatisch übernommen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">API-Anbindung:</strong> Jedes Display ruft seine Live-Inhalte über eine JSON-API ab (<code class="text-xs bg-zinc-100 dark:bg-zinc-800 px-1 py-0.5 rounded">/api/display/{id}/config</code>). Entwürfe laufen über Preview-Tokens, Module über eigene Preview-Endpunkte.</li>
</ul>
</div>
@ -282,10 +284,12 @@ $tabletStatus = computed(function () {
Typischer Workflow
</flux:heading>
<ol class="mt-2 ml-5 list-decimal space-y-1">
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Medien hochladen</strong> Bilder, SVG-Logos oder Videos in der Display-Mediathek ablegen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Modul erstellen</strong> Unter „Module" ein neues Modul mit passendem Typ anlegen (z.&nbsp;B. „Frühling 2026 Video").</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Inhalte hinzufügen</strong> Im Modul Videos, Medien oder Slides anlegen, Reihenfolge festlegen und aktivieren.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Display zuweisen</strong> Unter „Displays" das Modul einem physischen Bildschirm zuordnen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Fertig</strong> Das Display lädt die neuen Inhalte automatisch über die API.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Meta-Einstellungen pflegen</strong> Logo, Claim, Footer, QR-Code, Theme oder Anzeigezeiten einmal auf Modulebene setzen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Inhalte hinzufügen</strong> Im Modul Videos, Medien oder Slides anlegen, Reihenfolge festlegen, aktivieren und per Vorschau prüfen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Display-Entwurf erstellen</strong> Unter „Displays" aus dem Live-Stand einen Entwurf erzeugen und dort Module hinzufügen, sortieren oder entfernen.</li>
<li><strong class="font-medium text-zinc-800 dark:text-zinc-200">Prüfen und veröffentlichen</strong> Entwurf in der 9:16-Vorschau oder im Vollbild testen und anschließend bewusst veröffentlichen.</li>
</ol>
</div>

View file

@ -10,6 +10,29 @@
</x-success-alert>
@endif
@php
$displayPlayerUrl = rtrim(config('display.player_url') ?: 'https://cabinet.b2in.eu/display', '/');
$displayOverviewUrl = $displayPlayerUrl.'/';
@endphp
<flux:card class="mb-6 border-blue-200 bg-blue-50/70 dark:border-blue-500/30 dark:bg-blue-950/20">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<flux:heading size="lg">{{ __('Öffentliche Display-Übersicht') }}</flux:heading>
<flux:text class="mt-1">
{{ __('Hier sehen Sie alle aktiven Live-Displays und können die Wiedergabe direkt öffnen.') }}
</flux:text>
<div class="mt-2 text-xs font-mono text-blue-700 dark:text-blue-300">
{{ $displayOverviewUrl }}
</div>
</div>
<flux:button href="{{ $displayOverviewUrl }}" target="_blank" variant="primary" icon="arrow-top-right-on-square">
{{ __('Display-Übersicht öffnen') }}
</flux:button>
</div>
</flux:card>
<flux:card>
<div class="flex items-center justify-between mb-6">
<div>
@ -30,7 +53,7 @@
<div class="space-y-4">
@foreach($displays as $display)
@php
$liveDisplayUrl = url('/_cabinet/display/index.html').'?id='.$display->id;
$liveDisplayUrl = $displayPlayerUrl.'/?id='.$display->id;
$liveApiUrl = url('/api/display/'.$display->id.'/config');
@endphp
<div wire:key="display-{{ $display->id }}"
@ -364,6 +387,7 @@
wire:key="draft-preview-{{ $previewFrameRefreshCounter }}"
src="{{ $draftPreviewUrl }}"
class="h-full w-full border-0"
loading="lazy"
title="{{ __('Entwurfs-Vorschau') }}"
></iframe>
@else

View file

@ -70,6 +70,7 @@
wire:key="module-preview-{{ $previewFrameRefreshCounter }}"
src="{{ $this->modulePreviewUrl() }}"
class="h-full w-full border-0"
loading="lazy"
title="{{ __('Modul-Vorschau') }}"
></iframe>
</div>
@ -250,6 +251,7 @@
wire:key="item-modal-module-preview-{{ $previewFrameRefreshCounter }}"
src="{{ $this->itemPreviewUrl() }}"
class="h-full w-full border-0"
loading="lazy"
title="{{ __('Einzel-Vorschau im Bearbeiten-Dialog') }}"
></iframe>
</div>

View file

@ -3,7 +3,7 @@
accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml,.pdf,.doc,.docx,.jpg,.jpeg,.png">
<flux:file-upload.dropzone
heading="Dateien hochladen"
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 10 MB pro Datei"
text="Bilder (JPG, PNG, WebP, SVG) und Dokumente (PDF, DOC) — max. 200 MB pro Datei"
with-progress />
</flux:file-upload>

View file

@ -47,8 +47,15 @@
</flux:badge>
<span class="font-semibold text-sm">{{ $item->content['title'] ?? $item->content['filename'] ?? '' }}</span>
</div>
<div class="text-xs text-zinc-600 dark:text-zinc-400 space-x-4">
<span>{{ $item->content['filename'] ?? '' }}</span>
@php
$videoSource = $item->content['filename'] ?? '';
$isMediaLibrarySource = str_starts_with($videoSource, '/storage/') || str_starts_with($videoSource, 'http');
@endphp
<div class="flex flex-wrap items-center gap-2 text-xs text-zinc-600 dark:text-zinc-400">
<flux:badge size="sm" :color="$isMediaLibrarySource ? 'sky' : 'zinc'">
{{ $isMediaLibrarySource ? __('Mediathek') : __('Legacy-Datei') }}
</flux:badge>
<span class="truncate">{{ $videoSource ?: '' }}</span>
<span>Position: {{ $item->content['position'] ?? 25 }}%</span>
</div>
</div>

View file

@ -272,16 +272,16 @@ new class extends Component
$rules['deliveryRadius'] = 'required|integer|min:1|max:500';
$rules['assemblyRadius'] = 'required|integer|min:1|max:500';
$rules['newTeamPhotos'] = 'nullable|array|max:10';
$rules['newTeamPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:10240';
$rules['newTeamPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:204800';
$rules['newShowroomPhotos'] = 'nullable|array|max:20';
$rules['newShowroomPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:10240';
$rules['newShowroomPhotos.*'] = 'image|mimes:jpeg,jpg,png|max:204800';
}
if ($this->isManufacturer()) {
$rules['brandName'] = 'required|string|max:255';
$rules['brandDescription'] = 'nullable|string|max:1000';
$rules['newBrandImages'] = 'nullable|array|max:10';
$rules['newBrandImages.*'] = 'image|mimes:jpeg,jpg,png|max:10240';
$rules['newBrandImages.*'] = 'image|mimes:jpeg,jpg,png|max:204800';
}
$this->validate($rules, [
@ -305,11 +305,11 @@ new class extends Component
'foundedYear.min' => __('Das Gründungsjahr muss nach 1800 liegen.'),
'foundedYear.max' => __('Das Gründungsjahr darf nicht in der Zukunft liegen.'),
'newTeamPhotos.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'),
'newTeamPhotos.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'),
'newTeamPhotos.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
'newShowroomPhotos.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'),
'newShowroomPhotos.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'),
'newShowroomPhotos.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
'newBrandImages.*.image' => __('Nur Bilder (JPG, PNG) erlaubt.'),
'newBrandImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'),
'newBrandImages.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
]);
$specialties = array_values(array_filter(
@ -793,7 +793,7 @@ new class extends Component
<flux:card class="shadow-elegant">
<div class="mb-4">
<flux:heading size="lg">{{ __('Team-Fotos') }}</flux:heading>
<flux:subheading>{{ __('Nur JPG/PNG max. 10 MB pro Bild') }}</flux:subheading>
<flux:subheading>{{ __('Nur JPG/PNG max. 200 MB pro Bild') }}</flux:subheading>
</div>
<flux:separator class="mb-6" />
@ -867,7 +867,7 @@ new class extends Component
@endif
<flux:file-upload wire:model="newTeamPhotos" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png">
<flux:file-upload.dropzone heading="{{ __('Team-Fotos hochladen') }}" text="{{ __('JPEG oder PNG max. 10 MB') }}" with-progress />
<flux:file-upload.dropzone heading="{{ __('Team-Fotos hochladen') }}" text="{{ __('JPEG oder PNG max. 200 MB') }}" with-progress />
</flux:file-upload>
@if (count($newTeamPhotos) > 0)
@ -892,7 +892,7 @@ new class extends Component
<flux:card class="shadow-elegant">
<div class="mb-4">
<flux:heading size="lg">{{ __('Showroom-Galerie') }}</flux:heading>
<flux:subheading>{{ __('Bilder Ihres Showrooms für das öffentliche Profil nur JPG/PNG, max. 10 MB') }}</flux:subheading>
<flux:subheading>{{ __('Bilder Ihres Showrooms für das öffentliche Profil nur JPG/PNG, max. 200 MB') }}</flux:subheading>
</div>
<flux:separator class="mb-6" />
@ -966,7 +966,7 @@ new class extends Component
@endif
<flux:file-upload wire:model="newShowroomPhotos" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png">
<flux:file-upload.dropzone heading="{{ __('Showroom-Bilder hochladen') }}" text="{{ __('JPEG oder PNG max. 10 MB') }}" with-progress />
<flux:file-upload.dropzone heading="{{ __('Showroom-Bilder hochladen') }}" text="{{ __('JPEG oder PNG max. 200 MB') }}" with-progress />
</flux:file-upload>
@if (count($newShowroomPhotos) > 0)
@ -993,7 +993,7 @@ new class extends Component
<flux:card class="shadow-elegant">
<div class="mb-4">
<flux:heading size="lg">{{ __('Marken-Bilder') }}</flux:heading>
<flux:subheading>{{ __('Bilder für Ihre Marken-Präsentation (Atmosphäre, Brand-Story etc.) nur JPG/PNG, max. 10 MB') }}</flux:subheading>
<flux:subheading>{{ __('Bilder für Ihre Marken-Präsentation (Atmosphäre, Brand-Story etc.) nur JPG/PNG, max. 200 MB') }}</flux:subheading>
</div>
<flux:separator class="mb-6" />
@ -1067,7 +1067,7 @@ new class extends Component
@endif
<flux:file-upload wire:model="newBrandImages" multiple accept="image/jpeg,image/png,.jpg,.jpeg,.png">
<flux:file-upload.dropzone heading="{{ __('Marken-Bilder hochladen') }}" text="{{ __('JPEG oder PNG max. 10 MB') }}" with-progress />
<flux:file-upload.dropzone heading="{{ __('Marken-Bilder hochladen') }}" text="{{ __('JPEG oder PNG max. 200 MB') }}" with-progress />
</flux:file-upload>
@if (count($newBrandImages) > 0)

View file

@ -461,7 +461,7 @@ new class extends Component
'status' => 'required|in:active,draft',
// Bilder
'mainImages' => 'nullable|array|min:0|max:10',
'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240',
'mainImages.*' => 'mimes:jpeg,jpg,png|max:204800',
// Maße & Material
'widthCm' => 'nullable|integer|min:1',
'heightCm' => 'nullable|integer|min:1',
@ -546,7 +546,7 @@ new class extends Component
'priceDisplayText.required_if' => __('Bei Ab-Preis ist eine Preisangabe erforderlich (z.B. "Ab 2.500 €").'),
'mainImages.max' => __('Maximal 10 Produktbilder erlaubt.'),
'mainImages.*.mimes' => __('Nur Bilder (JPG, JPEG, PNG) erlaubt.'),
'mainImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'),
'mainImages.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
'sku.unique' => __('Diese Artikelnummer ist bereits vergeben.'),
'sellingPrice.min' => __('Der Verkaufspreis muss größer als 0 sein.'),
'countryOfOrigin.size' => __('Bitte geben Sie einen gültigen 2-stelligen ISO-Ländercode ein (z.B. DE).'),
@ -1229,7 +1229,7 @@ new class extends Component
<flux:card class="shadow-elegant">
<div class="mb-4">
<flux:heading size="lg">{{ __('Produktbilder') }}</flux:heading>
<flux:subheading>{{ __('Nur Bilder (JPG, JPEG, PNG) max. 10 MB pro Bild, max. 10 Bilder') }}
<flux:subheading>{{ __('Nur Bilder (JPG, JPEG, PNG) max. 200 MB pro Bild, max. 10 Bilder') }}
</flux:subheading>
</div>
<flux:separator class="mb-6" />
@ -1309,7 +1309,7 @@ new class extends Component
<flux:file-upload wire:model="mainImages" label="Upload files" multiple
accept="image/jpeg,image/png,.jpg,.jpeg,.png">
<flux:file-upload.dropzone heading="{{ __('Bilder hochladen') }}"
text="{{ __('Nur JPEG oder PNG max. 10 MB') }}" with-progress />
text="{{ __('Nur JPEG oder PNG max. 200 MB') }}" with-progress />
</flux:file-upload>
@if (isset($mainImages) && count($mainImages) > 0)

View file

@ -238,7 +238,7 @@ new class extends Component
'status' => 'required|in:active,draft',
'partnerProductNumber' => 'nullable|string|max:100',
'mainImages' => 'nullable|array|min:0|max:10',
'mainImages.*' => 'mimes:jpeg,jpg,png|max:10240',
'mainImages.*' => 'mimes:jpeg,jpg,png|max:204800',
];
$messages = [
@ -250,7 +250,7 @@ new class extends Component
'categoryId.required' => __('Bitte wählen Sie eine Kategorie aus.'),
'mainImages.max' => __('Maximal 10 Produktbilder erlaubt.'),
'mainImages.*.mimes' => __('Nur Bilder (JPG, JPEG, PNG) erlaubt.'),
'mainImages.*.max' => __('Bilder dürfen maximal 10 MB groß sein.'),
'mainImages.*.max' => __('Bilder dürfen maximal 200 MB groß sein.'),
];
if ($isAdminWithoutPartner) {
@ -459,7 +459,7 @@ new class extends Component
<flux:card class="shadow-elegant">
<div class="mb-4">
<flux:heading size="lg">{{ $isEditing ? __('Produktbilder') : __('Produktbild') }}</flux:heading>
<flux:subheading>{{ __('Nur Bilder (JPG, JPEG, PNG) max. 10 MB pro Bild, max. 10 Bilder') }}
<flux:subheading>{{ __('Nur Bilder (JPG, JPEG, PNG) max. 200 MB pro Bild, max. 10 Bilder') }}
</flux:subheading>
</div>
<flux:separator class="mb-6" />
@ -539,7 +539,7 @@ new class extends Component
<flux:file-upload wire:model="mainImages" label="Upload files" multiple
accept="image/jpeg,image/png,.jpg,.jpeg,.png">
<flux:file-upload.dropzone heading="{{ __('Bilder hochladen') }}"
text="{{ __('Nur JPEG oder PNG max. 10 MB') }}" with-progress />
text="{{ __('Nur JPEG oder PNG max. 200 MB') }}" with-progress />
</flux:file-upload>
@if (isset($mainImages) && count($mainImages) > 0)

View file

@ -66,10 +66,6 @@ Route::middleware(['auth', 'partner.setup'])->group(function () {
// Display CMS
Volt::route('admin/cms/display-dashboard', 'admin.cms.display-dashboard')->name('admin.cms.display-dashboard');
Volt::route('admin/cms/display-media', 'admin.cms.display-media-library')->name('admin.cms.display-media');
Route::redirect('admin/cms/display-versions', 'admin/cms/display-modules', 301)->name('admin.cms.display-versions');
Route::get('admin/cms/display-versions/{displayVersion}/edit', function (\App\Models\DisplayVersion $displayVersion) {
return redirect()->route('admin.cms.display-module-edit', $displayVersion, 301);
})->name('admin.cms.display-version-edit');
Route::get('admin/cms/display-modules', \App\Livewire\Admin\Cms\DisplayVersionList::class)->name('admin.cms.display-modules');
Route::get('admin/cms/display-modules/{displayVersion}/edit', \App\Livewire\Admin\Cms\DisplayVersionEditor::class)->name('admin.cms.display-module-edit');
Route::get('admin/cms/displays', \App\Livewire\Admin\Cms\DisplayList::class)->name('admin.cms.displays');

View file

@ -45,6 +45,7 @@ Route::domain($domainPortal)->group(function () {
Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']);
// Display Version API (per physical display)
Route::get('/api/display/overview', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'overview']);
Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']);
Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']);
Route::get('/api/display/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'config']);
@ -127,6 +128,7 @@ Route::get('/api/cabinet-tablet/status', [\App\Http\Controllers\Api\CabinetTable
Route::get('/api/cabinet-tablet/check', [\App\Http\Controllers\Api\CabinetTabletController::class, 'check']);
// Fallback: Display Version API
Route::get('/api/display/overview', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'overview']);
Route::get('/api/display/{display}/config', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'config']);
Route::get('/api/display/{display}/check', [\App\Http\Controllers\Api\DisplayVersionApiController::class, 'check']);
Route::get('/api/display/preview/{token}', [\App\Http\Controllers\Api\DisplayPreviewController::class, 'config']);

View file

@ -29,3 +29,10 @@ test('media picker zeigt ausgewähltes medium ohne livewire property fehler', fu
->assertSee('test-image.jpg')
->assertDontSee('Kein Medium ausgewählt');
});
test('cms media picker und library uploader nutzen 200 mb dateigrenze', function () {
expect(file_get_contents(app_path('Livewire/Admin/Cms/MediaPicker.php')))
->toContain('max:204800')
->and(file_get_contents(app_path('Livewire/Admin/Cms/MediaLibraryUploader.php')))
->toContain('max:204800');
});

View file

@ -79,9 +79,6 @@ test('can assign versions to a display', function () {
->call('save');
$display->refresh();
expect($display->versions)->toHaveCount(2);
expect($display->versions->first()->id)->toBe($version1->id);
expect($display->versions->last()->id)->toBe($version2->id);
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version1->id, $version2->id]);
});
@ -100,8 +97,6 @@ test('can reorder versions in playlist', function () {
->call('save');
$display->refresh();
expect($display->versions->first()->id)->toBe($version2->id);
expect($display->versions->last()->id)->toBe($version1->id);
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version2->id, $version1->id]);
});
@ -110,10 +105,7 @@ test('can remove version from playlist', function () {
$version1 = DisplayVersion::factory()->create();
$version2 = DisplayVersion::factory()->create();
$display = Display::factory()->create();
$display->versions()->attach([
$version1->id => ['sort_order' => 0],
$version2->id => ['sort_order' => 1],
]);
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$version1->id, $version2->id]);
Livewire::actingAs($user)
->test(DisplayList::class)
@ -122,8 +114,6 @@ test('can remove version from playlist', function () {
->call('save');
$display->refresh();
expect($display->versions)->toHaveCount(1);
expect($display->versions->first()->id)->toBe($version2->id);
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$version2->id]);
});
@ -233,7 +223,6 @@ test('can publish a draft playlist over the live playlist', function () {
expect($display->draftPlaylist)->toBeNull();
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id]);
expect($display->versions->pluck('id')->all())->toBe([$draftVersion->id]);
});
test('can edit live playlist without changing draft playlist', function () {
@ -277,7 +266,6 @@ test('can edit draft playlist without changing live playlist', function () {
expect($display->preview_token)->not->toBeNull();
expect($display->livePlaylist->modules->pluck('id')->all())->toBe([$liveVersion->id]);
expect($display->draftPlaylist->modules->pluck('id')->all())->toBe([$draftVersion->id, $newDraftVersion->id]);
expect($display->versions->pluck('id')->all())->toBe([]);
});
test('draft editor renders iframe preview url', function () {
@ -354,9 +342,26 @@ test('renders live and draft playlist columns', function () {
->test(DisplayList::class)
->assertSee('Live Modul')
->assertSee('Draft Modul')
->assertSee('Öffentliche Display-Übersicht')
->assertSee('Display-Übersicht öffnen')
->assertSee('https://cabinet.b2in.eu/display/', false)
->assertSee('Live bearbeiten')
->assertSee('Live-URL zum Kopieren')
->assertSee(url('/_cabinet/display/index.html').'?id='.$display->id, false)
->assertSee('https://cabinet.b2in.eu/display/?id='.$display->id, false)
->assertSee('Entwurf bearbeiten')
->assertSee('Test-Display');
});
test('display live urls fall back to cabinet domain when config is empty', function () {
config(['display.player_url' => '']);
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['name' => 'Live Modul']);
$display = Display::factory()->create();
createDisplayListPlaylist($display, DisplayPlaylist::STATUS_PUBLISHED, [$version->id]);
Livewire::actingAs($user)
->test(DisplayList::class)
->assertSee('https://cabinet.b2in.eu/display/', false)
->assertSee('https://cabinet.b2in.eu/display/?id='.$display->id, false);
});

View file

@ -4,7 +4,6 @@ use App\Models\Display;
use App\Models\DisplayPlaylist;
use App\Models\DisplayPlaylistItem;
use App\Models\DisplayVersion;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
it('creates the display_playlists table with unique status per display', function () {
@ -27,64 +26,8 @@ it('adds is_test and preview_token to displays', function () {
expect(Schema::hasColumns('displays', ['is_test', 'preview_token']))->toBeTrue();
});
it('migrates existing pivot entries into a published playlist with same ordering', function () {
$display = Display::factory()->create();
$moduleA = DisplayVersion::factory()->create(['name' => 'Modul A']);
$moduleB = DisplayVersion::factory()->create(['name' => 'Modul B']);
DB::table('display_playlists')->where('display_id', $display->id)->delete();
DB::table('display_display_version')->insert([
['display_id' => $display->id, 'display_version_id' => $moduleA->id, 'sort_order' => 0],
['display_id' => $display->id, 'display_version_id' => $moduleB->id, 'sort_order' => 1],
]);
$migration = require database_path('migrations/2026_05_11_113330_migrate_pivot_to_display_playlists.php');
$migration->up();
$playlist = DisplayPlaylist::query()
->where('display_id', $display->id)
->where('status', DisplayPlaylist::STATUS_PUBLISHED)
->first();
expect($playlist)->not->toBeNull();
expect($playlist->published_at)->not->toBeNull();
$orderedIds = $playlist->items()->pluck('display_version_id')->all();
expect($orderedIds)->toBe([$moduleA->id, $moduleB->id]);
});
it('is idempotent and does not duplicate published playlists on re-run', function () {
$display = Display::factory()->create();
$module = DisplayVersion::factory()->create();
DB::table('display_playlists')->where('display_id', $display->id)->delete();
DB::table('display_display_version')->insert([
['display_id' => $display->id, 'display_version_id' => $module->id, 'sort_order' => 0],
]);
$migration = require database_path('migrations/2026_05_11_113330_migrate_pivot_to_display_playlists.php');
$migration->up();
$migration->up();
$count = DisplayPlaylist::query()
->where('display_id', $display->id)
->where('status', DisplayPlaylist::STATUS_PUBLISHED)
->count();
expect($count)->toBe(1);
});
it('does not break the legacy versions() relation', function () {
$display = Display::factory()->create();
$module = DisplayVersion::factory()->create();
DB::table('display_display_version')->insert([
['display_id' => $display->id, 'display_version_id' => $module->id, 'sort_order' => 0],
]);
expect($display->fresh()->versions)->toHaveCount(1);
it('drops the legacy display version pivot table', function () {
expect(Schema::hasTable('display_display_version'))->toBeFalse();
});
it('exposes a live playlist relation and a draft playlist relation on display', function () {

View file

@ -214,14 +214,34 @@ test('check endpoint returns only updated_at', function () {
$response->assertJsonStructure(['updated_at']);
});
test('display config ignores legacy pivot and reads published playlist', function () {
$legacyVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Legacy']);
DisplayVersionItem::factory()->create([
'display_version_id' => $legacyVersion->id,
'item_type' => 'video',
'content' => ['filename' => 'legacy.mp4', 'title' => 'Legacy', 'position' => 25],
test('display overview lists active displays with published modules', function () {
$liveVersion = DisplayVersion::factory()->create(['name' => 'Live Module']);
$activeDisplay = Display::factory()->create([
'name' => 'Showroom Eingang',
'location' => 'Bielefeld',
'is_active' => true,
]);
publishDisplayModules($activeDisplay, [$liveVersion->id]);
$inactiveDisplay = Display::factory()->create(['is_active' => false]);
publishDisplayModules($inactiveDisplay, [$liveVersion->id]);
Display::factory()->create(['name' => 'Ohne Live']);
$response = $this->getJson('/api/display/overview');
$response->assertSuccessful()
->assertJsonCount(1, 'displays')
->assertJsonPath('displays.0.id', $activeDisplay->id)
->assertJsonPath('displays.0.name', 'Showroom Eingang')
->assertJsonPath('displays.0.location', 'Bielefeld')
->assertJsonPath('displays.0.is_active', true)
->assertJsonPath('displays.0.is_live', true)
->assertJsonPath('displays.0.module_count', 1)
->assertJsonPath('displays.0.url', 'https://cabinet.b2in.eu/display/?id='.$activeDisplay->id);
});
test('display config reads published playlist', function () {
$publishedVersion = DisplayVersion::factory()->create(['type' => 'video-display', 'name' => 'Published']);
DisplayVersionItem::factory()->create([
'display_version_id' => $publishedVersion->id,
@ -230,7 +250,6 @@ test('display config ignores legacy pivot and reads published playlist', functio
]);
$display = Display::factory()->create();
$display->versions()->attach($legacyVersion->id, ['sort_order' => 0]);
publishDisplayModules($display, [$publishedVersion->id]);
$response = $this->getJson("/api/display/{$display->id}/config");
@ -391,6 +410,11 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () {
->toContain('width: min(100vw, calc(100vh * 9 / 16));')
->toContain('height: min(100vh, calc(100vw * 16 / 9));')
->toContain('container-type: size;')
->toContain("if (hostname === 'cabinet.b2in.eu') {")
->toContain("return 'https://portal.b2in.eu';")
->toContain('/api/display/overview')
->toContain('Aktive Live-Displays')
->toContain('renderOverview(data.displays || [])')
->toContain('translate(${offsetX}px, ${offsetY}px) scale(${scale})')
->toContain('this.settings.logo_url')
->toContain('this.settings.footer_claim')

View file

@ -36,6 +36,20 @@ test('display version list renders for authenticated users', function () {
$response->assertSeeLivewire(DisplayVersionList::class);
});
test('display dashboard documentation describes current workflow', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->get(route('admin.cms.display-dashboard'))
->assertSuccessful()
->assertSee('Live und Entwurf')
->assertSee('Meta-Einstellungen pflegen')
->assertSee('SVG-Logos')
->assertSee('/storage/...')
->assertSee('Entwurf in der 9:16-Vorschau');
});
test('can create a display version', function () {
$user = User::factory()->create();
@ -104,19 +118,6 @@ test('display version editor renders with correct version data', function () {
$response->assertSeeLivewire(DisplayVersionEditor::class);
});
test('old display version routes redirect to module routes', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create();
$this->actingAs($user);
$this->get(route('admin.cms.display-versions'))
->assertRedirect(route('admin.cms.display-modules'));
$this->get(route('admin.cms.display-version-edit', $version))
->assertRedirect(route('admin.cms.display-module-edit', $version));
});
test('display module editor renders module preview', function () {
$user = User::factory()->create();
$version = DisplayVersion::factory()->create(['name' => 'Preview Modul']);