dashboard - first draft of complete redesign
This commit is contained in:
@@ -1,236 +1,435 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const CONTENT_REFRESH_MS = 30_000;
|
||||
const INDOOR_REFRESH_MS = 10_000;
|
||||
const OUTDOOR_REFRESH_MS = 30_000;
|
||||
const VERTICAL_SPEED_PX_PER_SECOND = 7;
|
||||
const HORIZONTAL_SPEED_PX_PER_SECOND = 10;
|
||||
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);
|
||||
|
||||
const WEATHER_ICONS = {
|
||||
cloudy: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><defs><filter id="weather-blur" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur in="SourceAlpha" stdDeviation="2.5"/><feOffset dx="0" dy="2" result="off"/><feComponentTransfer><feFuncA type="linear" slope="0.15"/></feComponentTransfer><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><g filter="url(#weather-blur)"><path d="M22 46h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 20 29a9 9 0 0 0 2 17z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M18 46h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 17 31a8 8 0 0 0 1 15z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></svg>`,
|
||||
sun: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><circle cx="32" cy="32" r="10" fill="#FFD34D" stroke="#fff"/><path d="M32 6v8M32 50v8M6 32h8M50 32h8M13.5 13.5l5.7 5.7M44.8 44.8l5.7 5.7M50.5 13.5l-5.7 5.7M19.2 44.8l-5.7 5.7"/></g></svg>`,
|
||||
moon: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><path d="M41 8a22 22 0 1 0 15 35A18 18 0 0 1 41 8z" fill="#DDE7FF" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></svg>`,
|
||||
cloudy_day: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><g transform="translate(4,0)"><circle cx="22" cy="22" r="9" fill="#FFD34D" stroke="#fff" stroke-width="2"/><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M22 6v6M22 32v6M6 22h6M32 22h6M10.5 10.5l4.2 4.2M29.3 29.3l4.2 4.2M33.5 10.5l-4.2 4.2M14.7 29.3l-4.2 4.2"/></g></g><g transform="translate(8,10)"><path d="M18 36h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 16 19a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M22 36h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 21 21a8 8 0 0 0 1 15z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></g></svg>`,
|
||||
cloudy_night: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M38 10a16 16 0 1 0 11 26A13 13 0 0 1 38 10z" fill="#DDE7FF" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g transform="translate(10,18)"><path d="M14 28h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 12 11a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M18 28h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 17 13a8 8 0 0 0 1 15z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></g></svg>`,
|
||||
rain_shower: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 36h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 19a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M24 42l-3 6"/><path d="M34 42l-3 6"/><path d="M44 42l-3 6"/></g></g></svg>`,
|
||||
rain: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 34h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 17a9 9 0 0 0 2 17z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M22 40l-3 8"/><path d="M32 40l-3 8"/><path d="M42 40l-3 8"/><path d="M52 40l-3 8"/></g></g></svg>`,
|
||||
thunder: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M18 34h28a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 16 17a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M32 36l-6 12h7l-3 10 12-16h-7l3-6z" fill="#FFD34D" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></g></svg>`,
|
||||
snowy: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 34h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 17a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g fill="#fff"><circle cx="24" cy="44" r="2"/><circle cx="34" cy="44" r="2"/><circle cx="44" cy="44" r="2"/><circle cx="29" cy="52" r="2"/><circle cx="39" cy="52" r="2"/></g></g></svg>`,
|
||||
mist: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity="0.95"><path d="M14 24h36"/><path d="M10 32h44"/><path d="M14 40h36"/><path d="M18 48h28"/></g></svg>`,
|
||||
};
|
||||
function node(tag, className, text) {
|
||||
const element = document.createElement(tag);
|
||||
element.className = className;
|
||||
if (text !== undefined) element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
|
||||
function setText(selector, value) {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
element.textContent = value ?? '';
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function updateClock() {
|
||||
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 clock = document.getElementById('uhrzeit');
|
||||
if (clock) {
|
||||
clock.textContent = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnnouncements(announcements) {
|
||||
const container = document.querySelector('.news-scroll');
|
||||
if (!container || !Array.isArray(announcements)) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.replaceChildren();
|
||||
announcements.forEach((announcement) => {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'news-box';
|
||||
|
||||
const date = document.createElement('div');
|
||||
date.className = 'news-date';
|
||||
date.append(document.createTextNode(announcement.date_label || ''));
|
||||
if (announcement.end_label) {
|
||||
date.append(document.createElement('br'), document.createTextNode(announcement.end_label));
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.append(document.createTextNode(announcement.title || ''));
|
||||
|
||||
if (announcement.subtitle) {
|
||||
const subtitle = document.createElement('div');
|
||||
subtitle.className = 'news-subtitle';
|
||||
subtitle.textContent = announcement.subtitle;
|
||||
body.append(subtitle);
|
||||
}
|
||||
|
||||
if (announcement.meta) {
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'news-subtitle';
|
||||
const italic = document.createElement('i');
|
||||
italic.textContent = announcement.meta;
|
||||
meta.append(italic);
|
||||
body.append(meta);
|
||||
}
|
||||
|
||||
box.append(date, body);
|
||||
container.append(box);
|
||||
});
|
||||
const refreshContent = createRefresh('content', contentLoaded, contentUnavailable);
|
||||
const refreshImages = createRefresh('images', data => {
|
||||
imageData = data;
|
||||
return layoutImages();
|
||||
}, imagesUnavailable);
|
||||
function refreshAll() {
|
||||
return Promise.all([refreshContent(), refreshImages()]);
|
||||
}
|
||||
|
||||
function renderPosters(posters) {
|
||||
const container = document.querySelector('.aushang-flex');
|
||||
if (!container || !Array.isArray(posters)) {
|
||||
return;
|
||||
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);
|
||||
}
|
||||
|
||||
container.replaceChildren();
|
||||
if (posters.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.style.textAlign = 'left';
|
||||
empty.textContent = 'Keine Aushänge vorgeplant.';
|
||||
container.append(empty);
|
||||
return;
|
||||
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)');
|
||||
}
|
||||
});
|
||||
|
||||
posters.forEach((poster) => {
|
||||
const image = document.createElement('img');
|
||||
image.src = poster.url;
|
||||
image.alt = poster.alt || 'Aushang';
|
||||
image.style.height = '95%';
|
||||
image.addEventListener('load', () => scheduleScrollingSetup(0), { once: true });
|
||||
container.append(image);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMonthImages(images) {
|
||||
if (!Array.isArray(images)) {
|
||||
return;
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
tick();
|
||||
// Nach Rückkehr bekommt die sichtbare Seite erneut ihre volle Lesezeit.
|
||||
posters.schedule();
|
||||
}
|
||||
|
||||
images.forEach((image) => {
|
||||
const element = document.querySelector(`[data-month-page="${image.page}"]`);
|
||||
if (element && image.url && element.getAttribute('src') !== image.url) {
|
||||
element.src = image.url;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderIndoor(indoor) {
|
||||
if (indoor) {
|
||||
setText('#temperature_txt', `${indoor.value}${indoor.unit || '°C'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderOutdoor(outdoor) {
|
||||
const weather = document.getElementById('wetter');
|
||||
if (!weather || !outdoor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = WEATHER_ICONS[outdoor.icon] || WEATHER_ICONS.cloudy;
|
||||
weather.replaceChildren();
|
||||
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.innerHTML = icon;
|
||||
const temperature = document.createElement('div');
|
||||
temperature.className = 'text-outdoor';
|
||||
temperature.textContent = `${outdoor.temperature}°C`;
|
||||
weather.append(iconContainer, temperature);
|
||||
}
|
||||
|
||||
function renderContent(data) {
|
||||
setText('[data-dashboard-date]', data.date_label);
|
||||
if (data.service) {
|
||||
setText('.bible', data.service.bibelwort);
|
||||
setText('.verse', data.service.bibelvers);
|
||||
setText('.GB', data.service.eingang_nummer);
|
||||
setText('.GB-title', data.service.eingang_titel);
|
||||
}
|
||||
|
||||
renderAnnouncements(data.announcements);
|
||||
renderPosters(data.posters);
|
||||
renderMonthImages(data.month_images);
|
||||
scheduleScrollingSetup();
|
||||
}
|
||||
|
||||
function startPolling(resource, interval, render) {
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const response = await fetch(`data.php?resource=${encodeURIComponent(resource)}`, { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
render(data);
|
||||
} catch (error) {
|
||||
console.error(`Dashboard-Aktualisierung (${resource}) fehlgeschlagen:`, error);
|
||||
} finally {
|
||||
window.setTimeout(refresh, interval);
|
||||
}
|
||||
};
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
function applyAnimation(element, distance, speed, animationName) {
|
||||
element.style.animation = 'none';
|
||||
if (distance <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variable = animationName === 'scroll-h' ? '--scroll-distance-h' : '--scroll-distance-v';
|
||||
element.style.setProperty(variable, `-${distance}px`);
|
||||
element.style.animationName = animationName;
|
||||
element.style.animationDuration = `${Math.max(1, distance / speed)}s`;
|
||||
element.style.animationTimingFunction = 'linear';
|
||||
element.style.animationIterationCount = 'infinite';
|
||||
}
|
||||
|
||||
function setupScrolling() {
|
||||
const posterContainer = document.querySelector('.aushang-flex');
|
||||
if (posterContainer) {
|
||||
const images = Array.from(posterContainer.querySelectorAll('img'));
|
||||
const totalWidth = images.reduce((sum, image) => {
|
||||
const styles = window.getComputedStyle(image);
|
||||
return sum + image.offsetWidth + (parseFloat(styles.marginRight) || 0);
|
||||
}, 0);
|
||||
applyAnimation(
|
||||
posterContainer,
|
||||
Math.max(0, totalWidth - posterContainer.offsetWidth),
|
||||
HORIZONTAL_SPEED_PX_PER_SECOND,
|
||||
'scroll-h'
|
||||
);
|
||||
}
|
||||
|
||||
const newsContainer = document.querySelector('.news-container');
|
||||
const news = document.querySelector('.news-scroll');
|
||||
if (newsContainer && news) {
|
||||
applyAnimation(
|
||||
news,
|
||||
Math.max(0, news.scrollHeight - newsContainer.offsetHeight),
|
||||
VERTICAL_SPEED_PX_PER_SECOND,
|
||||
'scroll-v'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let scrollingTimer = null;
|
||||
function scheduleScrollingSetup(delay = 80) {
|
||||
if (scrollingTimer !== null) {
|
||||
window.clearTimeout(scrollingTimer);
|
||||
}
|
||||
scrollingTimer = window.setTimeout(setupScrolling, delay);
|
||||
}
|
||||
|
||||
updateClock();
|
||||
window.setInterval(updateClock, 1000);
|
||||
window.addEventListener('load', () => scheduleScrollingSetup(50));
|
||||
window.addEventListener('resize', () => scheduleScrollingSetup(100));
|
||||
startPolling('content', CONTENT_REFRESH_MS, renderContent);
|
||||
startPolling('indoor', INDOOR_REFRESH_MS, (data) => renderIndoor(data.indoor));
|
||||
startPolling('outdoor', OUTDOOR_REFRESH_MS, (data) => renderOutdoor(data.outdoor));
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user