Display CMS Optimierungen 29-05-2026

- Mediathek: Video-Vorschaubilder statt Icons (FFmpeg-Thumbnails + Backfill-Command), Kategorie "Sonstiges"
- B2in Media-Picker zeigt alle Medientypen, Typ wird automatisch erkannt; Thumbnail-Preview vor allen Medien-URL-Feldern
- B2in Marke/Footer: Footer ein/aus, Logo+Claim frei positionierbar (Ecken) mit Constraints, separate Anzeige-Schalter
- Angebote-Modul dynamisch: kein Slide-Typ mehr, einheitliches Detail-Layout mit ein-/ausblendbaren Bloecken, Logo/Brand pro Slide, Streichpreis-Option
- Player: leere Module stoppen Endlosschleife, dynamische Layout-Anpassung bei verstecktem Footer/Header
- Fix: Script-Ladereihenfolge (Livewire vor Flux), entfernte stale public/flux/flux.js, Modal-Crash beim Aktualisieren behoben

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-05-29 15:57:33 +00:00
parent 9262132325
commit 6c6d683b9a
42 changed files with 2267 additions and 13905 deletions

View file

@ -143,6 +143,24 @@
text-transform: uppercase; color: rgba(255,255,255,0.7);
}
/* B2in Brand (positionable logo + claim) */
.b2in-brand { position: absolute; z-index: 12; display: flex; align-items: center; max-width: 60%; }
.b2in-brand-logo img { height: 3.5vh; display: block; filter: drop-shadow(0 1px 4px rgba(0,0,0,0.45)); }
.b2in-brand-claim {
font-size: 1.3vh; font-weight: 300; letter-spacing: 0.15em;
text-transform: uppercase; color: rgba(255,255,255,0.85);
text-shadow: 0 1px 4px rgba(0,0,0,0.55);
}
.b2in-brand.pos-top-left { top: 2.5vh; left: 3vh; }
.b2in-brand.pos-top-right { top: 2.5vh; right: 3vh; }
.b2in-brand.pos-bottom-left { bottom: 2.5vh; left: 3vh; }
.b2in-brand.pos-bottom-right { bottom: 2.5vh; right: 3vh; }
/* Legibility scrims behind positioned brand elements */
.b2in-scrim { position: absolute; left: 0; right: 0; height: 12vh; z-index: 11; pointer-events: none; }
.b2in-scrim-top { top: 0; background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent); }
.b2in-scrim-bottom { bottom: 0; background: linear-gradient(to top, rgba(0,0,0,0.6), transparent); }
/* B2in Media */
.b2in-media {
flex: 1; position: relative; overflow: hidden;
@ -171,6 +189,8 @@
font-size: 1.8vh; font-weight: 300; color: rgba(255,255,255,0.7);
line-height: 1.4;
}
/* Without footer the text reclaims the footer's space at the bottom */
.b2in-layer.no-footer .b2in-text { padding-bottom: 4vh; }
/* B2in Footer */
.b2in-footer {
@ -204,6 +224,9 @@
background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent);
}
.b2in-layer[data-theme="light"] .b2in-claim { color: rgba(43,63,81,0.6); }
.b2in-layer[data-theme="light"] .b2in-brand-claim { color: rgba(43,63,81,0.75); text-shadow: 0 1px 3px rgba(255,255,255,0.5); }
.b2in-layer[data-theme="light"] .b2in-scrim-top { background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent); }
.b2in-layer[data-theme="light"] .b2in-scrim-bottom { background: linear-gradient(to top, rgba(247,248,250,0.9), transparent); }
.b2in-layer[data-theme="light"] .b2in-text {
background: linear-gradient(to top, rgba(247,248,250,0.85) 40%, transparent);
}
@ -325,6 +348,10 @@
font-size: 24px; color: #737373; text-align: right;
line-height: 1.35; font-weight: 400;
}
.offer-price-note.strike {
color: #dc2626; text-decoration: line-through;
text-decoration-color: #dc2626; text-decoration-thickness: 3px;
}
/* Bullets */
.offer-bullets {
@ -412,6 +439,16 @@
.status-error { color: #ef4444; font-weight: 500; }
.status-sub { font-size: 1.4vh; opacity: 0.4; margin-top: 1vh; }
.display-empty {
position: absolute; inset: 0; z-index: 10;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 6vw;
background: #000; color: #fff;
}
.display-empty__title { font-size: 2.4vh; font-weight: 600; }
.display-empty__hint { font-size: 1.6vh; opacity: 0.5; margin-top: 1.2vh; font-weight: 300; }
.display-overview {
position: fixed; inset: 0; z-index: 10000;
overflow-y: auto; background: radial-gradient(circle at top, #12364d 0, #05070a 42%, #000 100%);
@ -549,6 +586,7 @@ class DisplayPlayer {
this.lastSuccessTime = Date.now();
this.isRunning = false;
this.activeVersionRenderer = null;
this.emptyVersionStreak = 0;
// DOM
this.viewport = document.getElementById('viewport');
@ -810,6 +848,19 @@ class DisplayPlayer {
return;
}
// Guard against a delay-free infinite restart loop when no version in the
// playlist has any playable content (e.g. a freshly created, empty module).
if (!this.versionHasContent(version)) {
this.emptyVersionStreak++;
if (this.emptyVersionStreak >= this.playlist.length) {
this.showEmptyPlaylist();
return;
}
this.advanceVersion();
return;
}
this.emptyVersionStreak = 0;
console.log(`[Display] Playing version ${this.currentVersionIndex + 1}/${this.playlist.length}: ${version.version_name} (${version.type})`);
// Clean up previous renderer
@ -850,6 +901,38 @@ class DisplayPlayer {
this.playCurrentVersion();
}
versionHasContent(version) {
if (!version) return false;
switch (version.type) {
case 'video-display':
return (version.videoPlaylist || []).length > 0;
case 'b2in':
return (version.items || []).some(item => item.is_active);
case 'offers':
return (version.slides || []).length > 0;
default:
return false;
}
}
showEmptyPlaylist() {
this.isRunning = false;
this.emptyVersionStreak = 0;
if (this.activeVersionRenderer) {
this.activeVersionRenderer.destroy();
this.activeVersionRenderer = null;
}
this.viewport.innerHTML = `
<div class="display-empty">
<p class="display-empty__title">Noch keine Inhalte vorhanden</p>
<p class="display-empty__hint">Sobald Inhalte angelegt und aktiviert sind, erscheinen sie hier.</p>
</div>
`;
console.log('[Display] Playlist has no playable content playback stopped.');
}
// ========================================
// UI HELPERS
// ========================================
@ -1132,7 +1215,7 @@ class B2inRenderer {
build() {
const layer = document.createElement('div');
layer.className = 'version-layer b2in-layer active';
layer.className = 'version-layer b2in-layer active' + (this.settings.show_footer === false ? ' no-footer' : '');
layer.setAttribute('data-theme', this.theme);
const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '../assets/b2in-logo-positive.svg');
@ -1145,11 +1228,57 @@ class B2inRenderer {
: '';
const qrUrl = normalizeQrUrl(this.settings.qr_url || footerUrl || 'b2in.eu');
layer.innerHTML = `
<header class="b2in-header">
// Footer visibility, brand element visibility + corner positioning
const showFooter = this.settings.show_footer !== false;
const showLogo = this.settings.show_logo !== false;
const showClaim = this.settings.show_claim !== false;
const validPositions = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
let logoPos = validPositions.includes(this.settings.logo_position) ? this.settings.logo_position : 'top-left';
let claimPos = validPositions.includes(this.settings.claim_position) ? this.settings.claim_position : 'top-right';
// Bottom corners only allowed when the footer is hidden
if (showFooter) {
if (logoPos.startsWith('bottom')) logoPos = logoPos.replace('bottom-', 'top-');
if (claimPos.startsWith('bottom')) claimPos = claimPos.replace('bottom-', 'top-');
}
// Claim must not share the logo corner
if (claimPos === logoPos) {
claimPos = (validPositions.filter(p => (showFooter ? p.startsWith('top') : true) && p !== logoPos)[0]) || claimPos;
}
const logoVisible = showLogo;
const claimVisible = showClaim && !!headerClaim;
const hasTop = (logoVisible && logoPos.startsWith('top')) || (claimVisible && claimPos.startsWith('top'));
const hasBottom = (logoVisible && logoPos.startsWith('bottom')) || (claimVisible && claimPos.startsWith('bottom'));
const logoHtml = logoVisible
? `<div class="b2in-brand b2in-brand-logo pos-${logoPos}">
<img src="${escapeHtml(headerLogoUrl)}" alt="B2in">
<span class="b2in-claim">${escapeHtml(headerClaim)}</span>
</header>
</div>`
: '';
const claimHtml = claimVisible
? `<div class="b2in-brand b2in-brand-claim pos-${claimPos}">${escapeHtml(headerClaim)}</div>`
: '';
const footerHtml = showFooter
? `<footer class="b2in-footer">
<div>
<span class="b2in-footer-url">${escapeHtml(footerUrl)}</span>
${footerNameHtml}
</div>
<div class="b2in-footer-qr">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&margin=5&data=${encodeURIComponent(qrUrl)}" alt="QR">
</div>
</footer>`
: '';
layer.innerHTML = `
${hasTop ? '<div class="b2in-scrim b2in-scrim-top"></div>' : ''}
${(hasBottom && !showFooter) ? '<div class="b2in-scrim b2in-scrim-bottom"></div>' : ''}
${logoHtml}
${claimHtml}
<section class="b2in-media">
<div class="b2in-media-layer active" id="b2in-layer-a"></div>
<div class="b2in-media-layer" id="b2in-layer-b"></div>
@ -1158,15 +1287,7 @@ class B2inRenderer {
<div class="b2in-headline" id="b2in-headline"></div>
<div class="b2in-subline" id="b2in-subline"></div>
</section>
<footer class="b2in-footer">
<div>
<span class="b2in-footer-url">${escapeHtml(footerUrl)}</span>
${footerNameHtml}
</div>
<div class="b2in-footer-qr">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&margin=5&data=${encodeURIComponent(qrUrl)}" alt="QR">
</div>
</footer>
${footerHtml}
<div class="b2in-progress-track">
<div class="b2in-progress-fill" id="b2in-progress"></div>
</div>
@ -1375,42 +1496,64 @@ class OffersRenderer {
}
buildSlide(slide) {
// Single dynamic detail layout: every block is toggleable. Older slides
// without explicit show_* flags fall back to "is there content?".
const has = v => v !== undefined && v !== null && v !== '';
const qrUrl = slide.qr_url || this.settings.footer_url || '';
const contactText = slide.contact || this.settings.footer_claim || '';
const show = {
logo: slide.show_logo ?? true,
badge: (slide.show_badge ?? has(slide.badge_text)) && has(slide.badge_text),
eyebrow: (slide.show_eyebrow ?? has(slide.eyebrow)) && has(slide.eyebrow),
subline: (slide.show_subline ?? has(slide.subline)) && has(slide.subline),
bullets: (slide.show_bullets ?? (slide.bullets && slide.bullets.length > 0)) && (slide.bullets && slide.bullets.length > 0),
price: (slide.show_price ?? has(slide.price)) && has(slide.price),
disclaimer: (slide.show_disclaimer ?? has(slide.disclaimer)) && has(slide.disclaimer),
qr: (slide.show_qr ?? has(slide.qr_url)) && has(qrUrl),
contact: (slide.show_contact ?? has(slide.contact)) && has(contactText),
};
const wrapper = document.createElement('div');
wrapper.className = 'offers-slide-container';
const article = document.createElement('article');
article.className = 'offer-slide';
// --- HEADER ---
const header = document.createElement('header');
header.className = 'offer-header';
// --- HEADER (Logo & Marke) per slide, toggleable ---
if (show.logo) {
const header = document.createElement('header');
header.className = 'offer-header';
const brand = document.createElement('div');
brand.className = 'offer-brand';
const brandLogo = document.createElement('img');
brandLogo.src = this.resolveUrl(this.settings.logo_url || '../logo-cabinet-300.png');
brandLogo.alt = 'CABINET';
brandLogo.className = 'offer-brand-logo';
brand.appendChild(brandLogo);
const brand = document.createElement('div');
brand.className = 'offer-brand';
const brandLogo = document.createElement('img');
brandLogo.src = this.resolveUrl(slide.logo_url || '../logo-cabinet-300.png');
brandLogo.alt = 'Logo';
brandLogo.className = 'offer-brand-logo';
brand.appendChild(brandLogo);
if (slide.show_brand_text) {
const brandText = document.createElement('span');
brandText.className = 'offer-brand-text';
brandText.textContent = this.settings.brand_text || 'Bielefeld';
brand.appendChild(brandText);
if (has(slide.brand_text)) {
const brandText = document.createElement('span');
brandText.className = 'offer-brand-text';
brandText.textContent = slide.brand_text;
brand.appendChild(brandText);
}
header.appendChild(brand);
if (has(slide.brand_tagline)) {
const tagline = document.createElement('div');
tagline.className = 'offer-tagline';
tagline.innerHTML = slide.brand_tagline.replace(/\n/g, '<br>');
header.appendChild(tagline);
}
article.appendChild(header);
} else {
// Without the header row, let the hero stay the flexible middle row.
article.style.gridTemplateRows = '1fr auto';
}
header.appendChild(brand);
if (slide.show_brand_text && slide.brand_tagline) {
const tagline = document.createElement('div');
tagline.className = 'offer-tagline';
tagline.innerHTML = slide.brand_tagline.replace(/\n/g, '<br>');
header.appendChild(tagline);
}
article.appendChild(header);
// --- HERO ---
const hero = document.createElement('section');
hero.className = 'offer-hero';
@ -1419,7 +1562,7 @@ class OffersRenderer {
hero.style.background = `url('${imgUrl}') center/cover no-repeat`;
}
if (slide.badge_text) {
if (show.badge) {
const badge = document.createElement('span');
badge.className = 'offer-hero-badge large';
badge.textContent = slide.badge_text;
@ -1439,7 +1582,7 @@ class OffersRenderer {
const infoContent = document.createElement('div');
infoContent.className = 'offer-info-content';
if (slide.eyebrow) {
if (show.eyebrow) {
const eyebrow = document.createElement('p');
eyebrow.className = 'offer-eyebrow';
eyebrow.textContent = slide.eyebrow;
@ -1448,22 +1591,19 @@ class OffersRenderer {
if (slide.title) {
const title = document.createElement('h1');
const titleSize = (slide.type === 'product-details') ? 'medium' : 'large';
title.className = `offer-title ${titleSize}`;
title.className = 'offer-title medium';
title.innerHTML = slide.title.replace(/\n/g, '<br>');
infoContent.appendChild(title);
}
// Subline (product-impulse)
if (slide.subline) {
if (show.subline) {
const subline = document.createElement('p');
subline.className = 'offer-subline';
subline.textContent = slide.subline;
infoContent.appendChild(subline);
}
// Bullets (product-details)
if (slide.bullets && slide.bullets.length > 0) {
if (show.bullets) {
const ul = document.createElement('ul');
ul.className = 'offer-bullets';
slide.bullets.forEach(text => {
@ -1477,8 +1617,8 @@ class OffersRenderer {
info.appendChild(infoContent);
// Price block (product-hero, product-impulse)
if (slide.price) {
// Price block
if (show.price) {
const priceBlock = document.createElement('div');
priceBlock.className = 'offer-price-block';
@ -1492,7 +1632,7 @@ class OffersRenderer {
if (slide.original_price) {
const note = document.createElement('div');
note.className = 'offer-price-note';
note.className = slide.strike_original_price ? 'offer-price-note strike' : 'offer-price-note';
note.textContent = slide.original_price;
priceRow.appendChild(note);
}
@ -1511,8 +1651,8 @@ class OffersRenderer {
info.appendChild(priceBlock);
}
// Disclaimer (intro)
if (slide.disclaimer) {
// Disclaimer
if (show.disclaimer) {
const footer = document.createElement('div');
footer.className = 'offer-info-footer';
const disc = document.createElement('span');
@ -1524,38 +1664,41 @@ class OffersRenderer {
bottom.appendChild(info);
// QR Box
const qrBox = document.createElement('aside');
qrBox.className = 'offer-qr-box';
// QR / Contact box only when at least one of the two is enabled.
if (show.qr || show.contact) {
const qrBox = document.createElement('aside');
qrBox.className = 'offer-qr-box';
const qrHeader = document.createElement('div');
qrHeader.className = 'offer-qr-header';
qrHeader.innerHTML = `
<p class="offer-qr-title">${this.escapeHtml(slide.qr_title || this.settings.qr_default_title || 'Kontakt')}</p>
<p class="offer-qr-subtitle">${this.escapeHtml(this.settings.qr_subtitle || 'QR scannen')}</p>
`;
qrBox.appendChild(qrHeader);
if (show.qr) {
const qrHeader = document.createElement('div');
qrHeader.className = 'offer-qr-header';
qrHeader.innerHTML = `
<p class="offer-qr-title">${this.escapeHtml(slide.qr_title || this.settings.qr_default_title || 'Kontakt')}</p>
<p class="offer-qr-subtitle">${this.escapeHtml(this.settings.qr_subtitle || 'QR scannen')}</p>
`;
qrBox.appendChild(qrHeader);
const qrWrapper = document.createElement('div');
qrWrapper.className = 'offer-qr-wrapper';
const qrUrl = slide.qr_url || this.settings.footer_url || '';
if (qrUrl) {
const qrImg = document.createElement('img');
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(normalizeQrUrl(qrUrl))}`;
qrImg.alt = 'QR Code';
qrWrapper.appendChild(qrImg);
}
qrBox.appendChild(qrWrapper);
const qrWrapper = document.createElement('div');
qrWrapper.className = 'offer-qr-wrapper';
const qrImg = document.createElement('img');
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(normalizeQrUrl(qrUrl))}`;
qrImg.alt = 'QR Code';
qrWrapper.appendChild(qrImg);
qrBox.appendChild(qrWrapper);
}
const contactText = slide.contact || this.settings.footer_claim || '';
if (contactText) {
const contact = document.createElement('p');
contact.className = 'offer-qr-contact';
contact.innerHTML = contactText.replace(/\n/g, '<br>');
qrBox.appendChild(contact);
if (show.contact) {
const contact = document.createElement('p');
contact.className = 'offer-qr-contact';
contact.innerHTML = contactText.replace(/\n/g, '<br>');
qrBox.appendChild(contact);
}
bottom.appendChild(qrBox);
} else {
bottom.style.gridTemplateColumns = '1fr';
}
bottom.appendChild(qrBox);
article.appendChild(bottom);
wrapper.appendChild(article);

File diff suppressed because it is too large Load diff