436 lines
20 KiB
JavaScript
436 lines
20 KiB
JavaScript
(() => {
|
|
'use strict';
|
|
|
|
const config = JSON.parse(document.getElementById('dashboard-config').textContent);
|
|
const root = document.body;
|
|
for (const [property, value] of Object.entries({
|
|
'--announcement-detail-font': 'calc(' + config.ANNOUNCEMENT_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))',
|
|
'--announcement-meta-font': 'calc(' + config.ANNOUNCEMENT_META_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))',
|
|
'--announcement-leading': config.ANNOUNCEMENT_LINE_HEIGHT,
|
|
'--bible-font': 'calc(' + config.BIBLE_FONT_SIZE_PX + ' * var(--dashboard-unit))',
|
|
'--announcement-measure': config.ANNOUNCEMENT_MAX_LINE_LENGTH_CH + 'ch',
|
|
})) root.style.setProperty(property, value);
|
|
|
|
function node(tag, className, text) {
|
|
const element = document.createElement(tag);
|
|
element.className = className;
|
|
if (text !== undefined) element.textContent = text;
|
|
return element;
|
|
}
|
|
|
|
function empty(stage, text) {
|
|
stage.replaceChildren(node('p', 'empty-state', text));
|
|
}
|
|
|
|
// Timeout und HTTP-Prüfung gelten für Inhalte und Bilder gleichermaßen.
|
|
async function request(url, read, options = {}) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), config.REQUEST_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
return await read(response);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
const aspectRatio = pages => pages.reduce((sum, item) => sum + item.width / item.height, 0);
|
|
const layoutKey = layout => [layout.width, layout.height, layout.groups.map(group => group.length)].join('/');
|
|
|
|
const dayFormatter = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
});
|
|
function berlinDay(date = new Date()) {
|
|
const parts = Object.fromEntries(dayFormatter.formatToParts(date).map(part => [part.type, part.value]));
|
|
return parts.year + '-' + parts.month + '-' + parts.day;
|
|
}
|
|
let currentDay = berlinDay();
|
|
|
|
function groupDocuments(pages) {
|
|
const entries = new Map();
|
|
for (const item of pages) {
|
|
const key = item.document ?? item.url;
|
|
if (!entries.has(key)) entries.set(key, []);
|
|
entries.get(key).push(item);
|
|
}
|
|
return [...entries.values()].map(pages => pages.sort((a, b) => a.page - b.page));
|
|
}
|
|
|
|
class ImageGallery {
|
|
constructor(id, message, rotate = false) {
|
|
Object.assign(this, {
|
|
message, rotate, items: [], current: [], images: new Map(),
|
|
index: 0, generation: 0, signature: '', layoutKey: '', failed: false, pending: false,
|
|
});
|
|
this.panel = document.getElementById(id);
|
|
this.stage = this.panel.querySelector('.image-stage');
|
|
this.progress = this.panel.querySelector('.poster-progress');
|
|
new ResizeObserver(() => this.resize()).observe(this.stage);
|
|
}
|
|
|
|
layout() {
|
|
// Keine Ganzzahlrundung durch clientHeight: Original-Bildhöhe exakt erhalten.
|
|
const bounds = this.stage.getBoundingClientRect();
|
|
const style = getComputedStyle(this.stage);
|
|
const width = bounds.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
|
const height = bounds.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
|
|
const gap = this.rotate ? parseFloat(style.columnGap) : 0;
|
|
const frame = this.rotate ? parseFloat(style.getPropertyValue('--poster-frame-width')) : 0;
|
|
if (!this.rotate) return { groups: [this.items], width, height, gap, frame };
|
|
const minimum = innerHeight * config.POSTER_MIN_RENDER_HEIGHT_VH / 100
|
|
- config.POSTER_MIN_RENDER_HEIGHT_OFFSET_PX;
|
|
const occupied = pages => minimum * aspectRatio(pages) + groupDocuments(pages).length * (2 * frame + gap);
|
|
const fits = pages => pages.length <= config.POSTER_MAX_VISIBLE_PAGES
|
|
&& occupied(pages) <= width + gap;
|
|
// Nur Aushänge aufteilen, die selbst nicht in eine Ansicht passen.
|
|
const chunks = [];
|
|
for (const pages of groupDocuments(this.items)) {
|
|
let chunk = [];
|
|
for (const item of pages) {
|
|
if (chunk.length && !fits([...chunk, item])) {
|
|
chunks.push(chunk);
|
|
chunk = [];
|
|
}
|
|
chunk.push(item);
|
|
}
|
|
if (chunk.length) chunks.push(chunk);
|
|
}
|
|
// Große Dokumente zuerst, kleinere in die Lücken: Datum ist nachrangig.
|
|
chunks.sort((a, b) => occupied(b) - occupied(a));
|
|
const groups = [];
|
|
for (const chunk of chunks) {
|
|
const group = groups.find(group => fits([...group, ...chunk]));
|
|
if (group) group.push(...chunk);
|
|
else groups.push([...chunk]);
|
|
}
|
|
// Zuerst vollständige Dokumente aus der vorigen Ansicht übernehmen,
|
|
// verbleibende Plätze nötigenfalls mit einzelnen Seiten auffüllen.
|
|
const primaryGroups = groups.map(group => [...group]);
|
|
for (let index = 0; groups.length > 1 && index < groups.length; index++) {
|
|
const current = groups[index];
|
|
const previous = primaryGroups[(index + groups.length - 1) % groups.length];
|
|
const available = previous.filter(item =>
|
|
!current.some(entry => entry.url === item.url));
|
|
for (const pages of groupDocuments(available).reverse()) {
|
|
if (fits([...current, ...pages])) current.unshift(...pages);
|
|
}
|
|
for (const item of [...available].reverse()) {
|
|
if (!current.some(entry => entry.url === item.url)
|
|
&& fits([...current, item])) current.unshift(item);
|
|
}
|
|
groups[index] = groupDocuments(current).flat();
|
|
}
|
|
return { groups, width, height, gap, frame };
|
|
}
|
|
|
|
loadImage(item) {
|
|
if (!this.images.has(item.url)) {
|
|
let localUrl;
|
|
this.images.set(item.url, request(item.url, async response => {
|
|
localUrl = URL.createObjectURL(await response.blob());
|
|
const image = new Image();
|
|
image.alt = item.alt;
|
|
image.dataset.source = item.url;
|
|
image.src = localUrl;
|
|
await image.decode();
|
|
return image;
|
|
}).catch(() => {
|
|
if (localUrl) URL.revokeObjectURL(localUrl);
|
|
this.images.delete(item.url);
|
|
return null;
|
|
}));
|
|
}
|
|
return this.images.get(item.url);
|
|
}
|
|
|
|
pruneImages() {
|
|
for (const [url, pending] of this.images) {
|
|
if (this.items.some(item => item.url === url) || this.current.some(item => item.url === url)) continue;
|
|
this.images.delete(url);
|
|
pending.then(image => { if (image) URL.revokeObjectURL(image.src); });
|
|
}
|
|
}
|
|
|
|
update(items, message = this.message) {
|
|
const signature = JSON.stringify([items, message]);
|
|
if (signature === this.signature && !this.failed) return;
|
|
this.signature = signature;
|
|
this.message = message;
|
|
this.items = items;
|
|
// Nur neue Bildversionen laden; unveränderte Blob-URLs wiederverwenden.
|
|
for (const item of items) this.loadImage(item);
|
|
if (!items.length) {
|
|
this.generation++;
|
|
clearTimeout(this.timer);
|
|
this.current = [];
|
|
this.pending = false;
|
|
empty(this.stage, message);
|
|
this.pruneImages();
|
|
this.schedule();
|
|
return;
|
|
}
|
|
// Nur abgelaufene Seiten sofort entfernen; sonst bleiben vorhandene
|
|
// Bilder bis zum vollständig geladenen Ersatz sichtbar.
|
|
if (this.rotate && this.current.some(item => item.expires < currentDay)) {
|
|
this.current = [];
|
|
empty(this.stage, this.message);
|
|
}
|
|
if (this.rotate && this.current.length) {
|
|
this.pending = true;
|
|
return;
|
|
}
|
|
return this.show(0);
|
|
}
|
|
|
|
resize() {
|
|
if (this.items.length && layoutKey(this.layout()) !== this.layoutKey) this.show(this.index);
|
|
}
|
|
|
|
schedule() {
|
|
clearTimeout(this.timer);
|
|
if (!this.rotate) return;
|
|
const duration = this.items.length ? config.POSTER_MIN_DWELL_TIME_S * 1000 : config.EMPTY_REFRESH_MS;
|
|
this.progressAnimation?.cancel();
|
|
this.progress.toggleAttribute('hidden', !this.items.length);
|
|
if (this.items.length) {
|
|
this.progressAnimation = this.progress.querySelector('.poster-progress-value').animate(
|
|
[{ strokeDashoffset: 100 }, { strokeDashoffset: 0 }],
|
|
{ duration, easing: 'linear', fill: 'forwards' });
|
|
}
|
|
this.timer = setTimeout(async () => {
|
|
if (this.items.length && !this.pending && this.index + 1 < this.layout().groups.length) {
|
|
return this.show(this.index + 1);
|
|
}
|
|
// Texte und Bildbestand nach einem vollständigen Durchlauf prüfen.
|
|
await this.onCycle?.();
|
|
if (this.items.length && (this.pending || this.failed || this.index !== 0)) this.show(0);
|
|
else this.schedule();
|
|
}, duration);
|
|
}
|
|
|
|
async show(index) {
|
|
if (!this.items.length) return;
|
|
this.pending = false;
|
|
const layout = this.layout();
|
|
this.layoutKey = layoutKey(layout);
|
|
this.index = index % layout.groups.length;
|
|
const batch = layout.groups[this.index];
|
|
const documents = this.rotate ? groupDocuments(batch) : [batch];
|
|
const renderHeight = Math.min(layout.height - 2 * layout.frame,
|
|
(layout.width - (documents.length - 1) * layout.gap - documents.length * 2 * layout.frame) / aspectRatio(batch));
|
|
const generation = ++this.generation;
|
|
clearTimeout(this.timer);
|
|
const sheet = node('div', 'image-sheet');
|
|
let failures = 0;
|
|
// Gemeinsam gerahmte Seiten laden unabhängig; erst die fertige Ansicht einblenden.
|
|
await Promise.all(documents.flatMap(pages => {
|
|
const wrapper = this.rotate ? node('div', 'poster-document') : sheet;
|
|
if (this.rotate) sheet.append(wrapper);
|
|
return pages.map(async item => {
|
|
const cell = node('div', 'image-cell');
|
|
cell.style.height = renderHeight + 'px';
|
|
cell.style.width = (renderHeight * item.width / item.height) + 'px';
|
|
wrapper.append(cell);
|
|
const copy = (await this.loadImage(item))?.cloneNode();
|
|
if (copy) {
|
|
try {
|
|
await copy.decode();
|
|
cell.append(copy);
|
|
return;
|
|
} catch { /* Die übrigen Seiten bleiben unabhängig darstellbar. */ }
|
|
}
|
|
failures++;
|
|
empty(cell, 'Seite zurzeit nicht verfügbar.');
|
|
});
|
|
}));
|
|
if (generation !== this.generation) return;
|
|
if (!batch.every(item => this.items.some(entry => entry.url === item.url))) return this.show(0);
|
|
this.failed = failures > 0;
|
|
// Einen vorhandenen Monatsplan bei defektem Ersatz nicht wegblenden.
|
|
if (!this.rotate && failures && this.stage.querySelector('img')) return;
|
|
this.current = batch;
|
|
this.stage.replaceChildren(sheet);
|
|
this.pruneImages();
|
|
if (failures === batch.length) empty(this.stage, 'Bilder zurzeit nicht verfügbar.');
|
|
this.schedule(); // Mindeststandzeit erst nach vollständigem Laden der Gruppe.
|
|
}
|
|
}
|
|
|
|
// Eine Textseite enthält ausschließlich DOM-Text, niemals HTML aus der Datenbank.
|
|
function paragraphs(className, entries) {
|
|
const container = node('div', className);
|
|
for (const [name, text] of entries) if (text) container.append(node('p', name, text));
|
|
return container;
|
|
}
|
|
|
|
function announcementList(items) {
|
|
const list = node('ul', 'text-page announcement-list');
|
|
for (const item of items) {
|
|
const entry = node('li', 'announcement-entry');
|
|
entry.append(paragraphs('announcement-when', [
|
|
['announcement-date', item.date_label || 'Ohne Datum'],
|
|
['announcement-end', item.end_label],
|
|
]), paragraphs('announcement-content', [
|
|
['announcement-title', item.title],
|
|
['announcement-subtitle', item.subtitle],
|
|
['announcement-meta', [item.time ? item.time + ' Uhr' : '', item.place].filter(Boolean).join(', ')],
|
|
]));
|
|
list.append(entry);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
class FixedText {
|
|
constructor(id, render = entries => paragraphs('text-page', entries)) {
|
|
this.stage = document.getElementById(id).querySelector('.text-stage');
|
|
this.render = render;
|
|
this.signature = '';
|
|
this.loaded = false;
|
|
new ResizeObserver(() => this.rebuild()).observe(this.stage);
|
|
}
|
|
|
|
update(documents, message) {
|
|
this.loaded = true;
|
|
const signature = JSON.stringify([documents, message]);
|
|
if (signature === this.signature) return;
|
|
this.signature = signature;
|
|
if (documents.length) this.stage.replaceChildren(this.render(documents));
|
|
else empty(this.stage, message);
|
|
this.rebuild();
|
|
}
|
|
|
|
unavailable(message) {
|
|
// Auch ein erfolgreich gelesener Leerzustand bleibt bei Abruffehlern stehen.
|
|
if (this.loaded) return;
|
|
empty(this.stage, message);
|
|
this.rebuild();
|
|
}
|
|
|
|
rebuild() {
|
|
const page = this.stage.querySelector('.text-page');
|
|
this.stage.classList.toggle('is-overflowing', Boolean(page && this.stage.clientHeight
|
|
&& page.getBoundingClientRect().height > this.stage.clientHeight + 1));
|
|
}
|
|
}
|
|
|
|
const month = new ImageGallery('month-panel', 'Kein Monatsplan vorhanden.');
|
|
const posters = new ImageGallery('poster-panel', 'Keine aktuellen Aushänge.', true);
|
|
const announcements = new FixedText('announcements-panel', announcementList);
|
|
const bible = new FixedText('bible-panel');
|
|
let imageData = null;
|
|
|
|
function layoutImages() {
|
|
if (!imageData) return;
|
|
// Monatsplan dauerhaft separat: keine Vermischung mit der Aushangrotation.
|
|
const months = imageData.month_images;
|
|
if (months.length) root.style.setProperty('--month-aspect', aspectRatio(months));
|
|
return Promise.all([
|
|
month.update(months),
|
|
posters.update(imageData.posters.filter(item => item.expires >= currentDay)),
|
|
]);
|
|
}
|
|
|
|
function contentLoaded(data) {
|
|
if (data.date !== currentDay) return contentUnavailable();
|
|
if (data.errors.includes('announcements')) announcements.unavailable('Ansagen zurzeit nicht verfügbar.');
|
|
else announcements.update(data.announcements, 'Es sind keine Ansagen eingetragen.');
|
|
|
|
const service = data.service;
|
|
const blocks = service ? [
|
|
['bible-reference', service.reference || 'Bibelstelle nicht eingetragen.'],
|
|
['bible-verse', service.text || 'Bibelvers nicht eingetragen.'],
|
|
] : [];
|
|
if (data.errors.includes('service')) bible.unavailable('Bibelwort zurzeit nicht verfügbar.');
|
|
else bible.update(blocks, 'Für heute ist kein Bibelwort eingetragen.');
|
|
}
|
|
|
|
function contentUnavailable() {
|
|
announcements.unavailable('Ansagen zurzeit nicht verfügbar.');
|
|
bible.unavailable('Bibelwort zurzeit nicht verfügbar.');
|
|
}
|
|
|
|
function imagesUnavailable() {
|
|
if (!imageData) {
|
|
month.update([], 'Monatsplan zurzeit nicht verfügbar.');
|
|
posters.update([], 'Aushänge zurzeit nicht verfügbar.');
|
|
}
|
|
}
|
|
|
|
// Unabhängige Hintergrundabrufe, ohne Ladezustand und ohne doppelte Anfragen.
|
|
function createRefresh(resource, onData, onError) {
|
|
let inFlight = null;
|
|
return () => inFlight ??= request('data.php?resource=' + resource,
|
|
async response => onData(await response.json()), { cache: 'no-store' })
|
|
.catch(onError).finally(() => { inFlight = null; });
|
|
}
|
|
|
|
const timeFormat = new Intl.DateTimeFormat('de-DE', {
|
|
timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit',
|
|
});
|
|
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
|
timeZone: 'Europe/Berlin', day: '2-digit', month: 'long', year: 'numeric',
|
|
});
|
|
function tick() {
|
|
const now = new Date();
|
|
const nextDay = berlinDay(now);
|
|
document.getElementById('clock').textContent = timeFormat.format(now);
|
|
document.getElementById('clock').dateTime = now.toISOString();
|
|
document.getElementById('date').textContent = dateFormat.format(now);
|
|
document.getElementById('date').dateTime = nextDay;
|
|
if (nextDay !== currentDay) {
|
|
currentDay = nextDay;
|
|
// Texte erst mit einer erfolgreichen Antwort austauschen; abgelaufene
|
|
// Aushänge weiterhin sofort entfernen.
|
|
layoutImages();
|
|
refreshAll();
|
|
}
|
|
}
|
|
|
|
const refreshContent = createRefresh('content', contentLoaded, contentUnavailable);
|
|
const refreshImages = createRefresh('images', data => {
|
|
imageData = data;
|
|
return layoutImages();
|
|
}, imagesUnavailable);
|
|
function refreshAll() {
|
|
return Promise.all([refreshContent(), refreshImages()]);
|
|
}
|
|
posters.onCycle = refreshAll;
|
|
refreshAll();
|
|
tick();
|
|
setInterval(tick, 1000);
|
|
document.fonts.ready.then(() => {
|
|
announcements.rebuild();
|
|
bible.rebuild();
|
|
// Ein Mindestkontrast wird auch bei später geänderten zentralen Tokens geprüft.
|
|
const style = getComputedStyle(root);
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = canvas.height = 1;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
function luminance(value) {
|
|
context.fillStyle = value;
|
|
context.fillRect(0, 0, 1, 1);
|
|
const channels = Array.from(context.getImageData(0, 0, 1, 1).data).slice(0, 3);
|
|
return channels.map(channel => {
|
|
const c = channel / 255;
|
|
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
}).reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
|
|
}
|
|
const background = Math.min(...Array.from(document.querySelectorAll(
|
|
'.dashboard-panel, .dashboard-overview, .panel-heading, .announcement-entry'),
|
|
panel => luminance(getComputedStyle(panel).backgroundColor)));
|
|
for (const [property, token] of [['--dashboard-text', '--nak-black'], ['--dashboard-meta', '--gray-700']]) {
|
|
const contrast = (background + 0.05) / (luminance(style.getPropertyValue(token)) + 0.05);
|
|
if (contrast < config.CONTENT_MIN_CONTRAST_RATIO) root.style.setProperty(property, 'var(--nak-black)');
|
|
}
|
|
});
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden) {
|
|
tick();
|
|
// Nach Rückkehr bekommt die sichtbare Seite erneut ihre volle Lesezeit.
|
|
posters.schedule();
|
|
}
|
|
});
|
|
})();
|