Display Module 13-05-2026
This commit is contained in:
parent
6a65354f4c
commit
9262132325
41 changed files with 496 additions and 334 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})");
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
61
app/Support/DisplayModuleSettings.php
Normal file
61
app/Support/DisplayModuleSettings.php
Normal 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 ?? []);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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,11 +25,12 @@ 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)
|
||||
└── 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 (17 Datensätze live)
|
||||
└── 1:n display_version_items
|
||||
├── item_type: video | footer | media | slide
|
||||
└── content: JSON
|
||||
```
|
||||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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. 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. 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue