Feature/dashboard rewrite v2 #27
@@ -23,6 +23,7 @@ web/intranet_repo/projects/contentpanel/insertion/news/*
|
||||
|
||||
# Generierte Dashboard-Terminbilder
|
||||
web/intranet_repo/projects/dashboard/month/*
|
||||
web/intranet_repo/projects/dashboard/cache/
|
||||
|
||||
# Lokale Backup-Dateien
|
||||
*.bak
|
||||
|
||||
@@ -9,7 +9,9 @@ if (!in_array($generalPermission, [1, '1', true, 't', 'true'], true)) {
|
||||
exit();
|
||||
}
|
||||
|
||||
contentpanel_cleanup_expired_news();
|
||||
$today = date('Y-m-d');
|
||||
contentpanel_cleanup_expired_news();
|
||||
contentpanel_cleanup_expired_announcements($pdo, $today);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = trim((string) ($_POST['action'] ?? ''));
|
||||
@@ -52,8 +54,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$flash = $_SESSION['contentpanel_flash'] ?? null;
|
||||
unset($_SESSION['contentpanel_flash']);
|
||||
|
||||
$today = date('Y-m-d');
|
||||
$services = contentpanel_fetch_services($pdo, $today);
|
||||
$services = contentpanel_fetch_services($pdo, $today);
|
||||
$announcements = contentpanel_fetch_announcements($pdo, $today);
|
||||
$newsItems = contentpanel_list_news();
|
||||
$pdo = null;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function contentpanel_cleanup_expired_announcements(PDO $pdo, string $today): void
|
||||
{
|
||||
// Das Enddatum ist einschließlich gültig; ohne Enddatum zählt das Startdatum.
|
||||
// Datensätze ohne beide Datumswerte werden nicht als abgelaufen gelöscht.
|
||||
$statement = $pdo->prepare(
|
||||
'DELETE FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?'
|
||||
);
|
||||
$statement->execute([$today]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
require_once __DIR__ . '/news.php';
|
||||
require_once __DIR__ . '/news_queue.php';
|
||||
require_once __DIR__ . '/announcements.php';
|
||||
|
||||
function contentpanel_html($value): string
|
||||
{
|
||||
@@ -294,6 +295,6 @@ function contentpanel_fetch_announcements(PDO $pdo, string $today): array
|
||||
return contentpanel_query($pdo,
|
||||
'SELECT id, ansage_titel AS title, ansage_untertitel AS subtitle, ansage_datum_von AS date,
|
||||
ansage_datum_bis AS end_date, ansage_uhrzeit AS time, ansage_ort AS location
|
||||
FROM ansagen WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ? ORDER BY ansage_datum_von',
|
||||
[$today, $today])->fetchAll(PDO::FETCH_ASSOC);
|
||||
FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) >= ? ORDER BY ansage_datum_von',
|
||||
[$today])->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Zentrale Anzeige- und Lesbarkeitswerte, ohne Build-Schritt.
|
||||
return [
|
||||
'POSTER_MIN_RENDER_HEIGHT_VH' => 39.61298125,
|
||||
'POSTER_MIN_RENDER_HEIGHT_OFFSET_PX' => 2.85866875,
|
||||
'POSTER_MAX_VISIBLE_PAGES' => 12,
|
||||
'POSTER_MIN_DWELL_TIME_S' => 12,
|
||||
'CONTENT_MIN_CONTRAST_RATIO' => 4.5,
|
||||
// Ohne Aushänge gibt es keinen Durchlauf; dann weiterhin regelmäßig prüfen.
|
||||
'EMPTY_REFRESH_MS' => 30000,
|
||||
'REQUEST_TIMEOUT_MS' => 8000,
|
||||
];
|
||||
@@ -1,236 +1,542 @@
|
||||
(() => {
|
||||
'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;
|
||||
|
||||
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 imageSize = item => item.crop || item;
|
||||
const aspectRatio = pages => pages.reduce((sum, item) =>
|
||||
sum + imageSize(item).width / imageSize(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,
|
||||
});
|
||||
const panel = document.getElementById(id);
|
||||
this.stage = panel.querySelector('.image-stage');
|
||||
this.progress = 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 = parseFloat(style.columnGap);
|
||||
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();
|
||||
if (!this.rotate) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(image, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let left = canvas.width, top = canvas.height, right = 0, bottom = 0;
|
||||
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) {
|
||||
if (!pixels[(y * canvas.width + x) * 4 + 3]) continue;
|
||||
left = Math.min(left, x); top = Math.min(top, y);
|
||||
right = Math.max(right, x + 1); bottom = Math.max(bottom, y + 1);
|
||||
}
|
||||
if (right) Object.defineProperty(item, 'crop', {
|
||||
value: { left, top, width: right - left, height: bottom - top }, configurable: true,
|
||||
});
|
||||
}
|
||||
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.
|
||||
const loading = items.map(item => 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.rotate ? this.show(0) : Promise.all(loading).then(() => 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 monthScale = this.rotate ? 0 : Math.min(layout.height / Math.max(...batch.map(item => imageSize(item).height)),
|
||||
(layout.width - (batch.length - 1) * layout.gap) / batch.reduce((sum, item) => sum + imageSize(item).width, 0));
|
||||
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');
|
||||
const size = imageSize(item);
|
||||
cell.style.height = ((this.rotate ? renderHeight : size.height * monthScale)) + 'px';
|
||||
cell.style.width = ((this.rotate ? renderHeight * item.width / item.height : size.width * monthScale)) + 'px';
|
||||
wrapper.append(cell);
|
||||
const copy = (await this.loadImage(item))?.cloneNode();
|
||||
if (copy) {
|
||||
try {
|
||||
await copy.decode();
|
||||
if (item.crop) {
|
||||
cell.classList.add('is-cropped');
|
||||
Object.assign(copy.style, {
|
||||
width: item.width / size.width * 100 + '%',
|
||||
height: item.height / size.height * 100 + '%',
|
||||
left: -size.left / size.width * 100 + '%',
|
||||
top: -size.top / size.height * 100 + '%',
|
||||
});
|
||||
}
|
||||
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',
|
||||
months.reduce((sum, item) => sum + item.width / item.height, 0));
|
||||
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',
|
||||
});
|
||||
const clock = document.getElementById('clock');
|
||||
const date = document.getElementById('date');
|
||||
function fitClock() {
|
||||
const width = clock.getBoundingClientRect().width;
|
||||
const target = date.getBoundingClientRect().width;
|
||||
if (width && Math.abs(width - target) > 1.5) {
|
||||
clock.style.fontSize = parseFloat(getComputedStyle(clock).fontSize) * target / width + 'px';
|
||||
}
|
||||
}
|
||||
new ResizeObserver(fitClock).observe(date);
|
||||
|
||||
const departures = new FixedText('departures-panel', rows => {
|
||||
const list = node('ul', 'text-page departures-list');
|
||||
for (const item of rows) {
|
||||
const row = node('li', 'departure-row is-' + item.state);
|
||||
row.title = item.detail;
|
||||
row.append(node('span', 'departure-line', item.line), node('span', 'departure-direction', item.direction),
|
||||
node('span', 'departure-remaining', item.remaining), node('span', 'departure-status', item.status));
|
||||
list.append(row);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
const departureData = { departures: [], updatedAt: 0 };
|
||||
let nextDepartureRequest = 0;
|
||||
function renderDepartures(now) {
|
||||
const age = now - departureData.updatedAt;
|
||||
// Der globale Feed-Zeitstempel kann älter sein; entscheidend sind die
|
||||
// Prognosen der einzelnen Fahrten und das Alter unseres letzten Abrufs.
|
||||
const fresh = age < 300000;
|
||||
const rows = (age < 7200000 ? departureData.departures : [])
|
||||
.map(d => ({ ...d, timestamp: Date.parse(d.when || d.plannedWhen) }))
|
||||
.filter(d => Number.isFinite(d.timestamp) && d.timestamp >= now)
|
||||
.sort((a, b) => a.timestamp - b.timestamp).slice(0, 5).map(d => {
|
||||
const live = fresh && Number.isFinite(d.delay) && Boolean(d.when);
|
||||
const state = !fresh ? 'stale' : d.cancelled ? 'cancelled' : !live ? 'scheduled'
|
||||
: d.delay > 0 ? 'delayed' : d.delay < 0 ? 'early' : 'ontime';
|
||||
const status = { stale: 'veraltet', cancelled: 'entfällt', scheduled: 'pünktlich', ontime: 'pünktlich',
|
||||
delayed: '+' + Math.ceil(d.delay / 60) + ' Min.', early: '−' + Math.ceil(-d.delay / 60) + ' Min.' }[state];
|
||||
const minutes = Math.ceil((d.timestamp - now) / 60000);
|
||||
const planned = Date.parse(d.plannedWhen);
|
||||
return { state, status, line: d.line?.name || 'Bus', direction: d.direction || 'Richtung unbekannt',
|
||||
remaining: d.cancelled ? '' : minutes ? minutes + ' Min' : 'jetzt',
|
||||
detail: (Number.isFinite(planned) ? 'Geplant ' + timeFormat.format(planned) + ' · ' : '')
|
||||
+ (d.direction || '') + ' · ' + status };
|
||||
});
|
||||
departures.update(rows, fresh ? 'Keine Busabfahrten in den nächsten 120 Minuten.' : 'Live-Abfahrten derzeit nicht verfügbar.');
|
||||
}
|
||||
const refreshDepartures = createRefresh('departures_eich', data => {
|
||||
if (!Array.isArray(data.departures) || !Number.isFinite(data.updatedAt)) throw new Error('Ungültige BVG-Antwort');
|
||||
Object.assign(departureData, data);
|
||||
nextDepartureRequest = Number(data.retryAt) * 1000 || 0;
|
||||
renderDepartures(Date.now());
|
||||
}, () => {
|
||||
departureData.unavailable = true;
|
||||
nextDepartureRequest = Date.now() + 30000;
|
||||
renderDepartures(Date.now());
|
||||
});
|
||||
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);
|
||||
clock.textContent = timeFormat.format(now);
|
||||
clock.dateTime = now.toISOString();
|
||||
date.textContent = dateFormat.format(now);
|
||||
date.dateTime = nextDay;
|
||||
fitClock();
|
||||
renderDepartures(now.getTime());
|
||||
if (!document.hidden && now.getTime() >= nextDepartureRequest) {
|
||||
nextDepartureRequest = now.getTime() + 1000;
|
||||
refreshDepartures();
|
||||
}
|
||||
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);
|
||||
const temperatureFormat = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 1 });
|
||||
const refreshTemperatures = [
|
||||
['indoor', data => [data.indoor.value, data.indoor.unit]],
|
||||
['outdoor', data => [data.outdoor.temperature, '°C']],
|
||||
].map(([id, read]) => {
|
||||
const output = document.getElementById(id);
|
||||
return createRefresh(id, data => {
|
||||
const [value, unit] = read(data);
|
||||
if (!Number.isFinite(value)) throw new Error('Ungültige Temperatur');
|
||||
const text = temperatureFormat.format(value) + ' ' + unit;
|
||||
if (output.textContent !== text) output.textContent = text;
|
||||
output.classList.remove('is-unavailable');
|
||||
}, () => {
|
||||
if (output.classList.contains('is-unavailable')) output.textContent = 'Nicht verfügbar';
|
||||
});
|
||||
});
|
||||
function refreshAll() {
|
||||
// Langsame Temperatursensoren dürfen den Aushangswechsel nicht verzögern.
|
||||
refreshTemperatures.forEach(refresh => refresh());
|
||||
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(() => {
|
||||
fitClock();
|
||||
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));
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -2,139 +2,70 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
date_default_timezone_set('Europe/Berlin');
|
||||
require_once __DIR__ . '/util/dashboard_helpers.php';
|
||||
|
||||
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
|
||||
$content = dashboard_fetch_content($pdo, dashboard_now());
|
||||
$pdo = null;
|
||||
|
||||
$service = $content['service'];
|
||||
$announcements = $content['announcements'];
|
||||
$posters = $content['posters'];
|
||||
$monthImages = $content['month_images'];
|
||||
$stylesheetVersion = (int) filemtime(__DIR__ . '/stylo.css');
|
||||
$scriptVersion = (int) filemtime(__DIR__ . '/dashboard.js');
|
||||
// Die Anzeigefläche muss auch ohne Datenbank oder externe APIs starten können.
|
||||
$config = require __DIR__ . '/config.php';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NAK Reinickendorf</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/intranet_repo/projects/_central/img/nak-logo.svg">
|
||||
<link rel="shortcut icon" href="/intranet_repo/projects/_central/img/favicon.ico">
|
||||
<link rel="stylesheet" href="stylo.css?v=<?= $stylesheetVersion ?>">
|
||||
<link href="https://fonts.googleapis.com/css2?family=SUSE:wght@400;700&display=swap" rel="stylesheet">
|
||||
<script src="dashboard.js?v=<?= $scriptVersion ?>" defer></script>
|
||||
<meta name="description" content="Aktuelle Informationen der Neuapostolischen Kirche Berlin-Reinickendorf.">
|
||||
<title>Gemeindeinformationen · NAK Berlin-Reinickendorf</title>
|
||||
<link rel="icon" type="image/svg+xml" href="../_central/img/nak-logo.svg">
|
||||
<link rel="stylesheet" href="../_central/layouts/nak-design.css">
|
||||
<link rel="stylesheet" href="stylo.css?v=<?= (int) filemtime(__DIR__ . '/stylo.css') ?>">
|
||||
<script id="dashboard-config" type="application/json"><?= json_encode($config, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?></script>
|
||||
<script src="dashboard.js?v=<?= (int) filemtime(__DIR__ . '/dashboard.js') ?>" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="content-board">
|
||||
<div class="content-column-right">
|
||||
<div class="content-column-right-upper-box">
|
||||
<div class="content-column-right-upper-box-right">
|
||||
<div class="content-item-time">
|
||||
<div class="time"><div id="uhrzeit"></div></div>
|
||||
<div class="date" style="text-align: center" data-dashboard-date><?= dashboard_html($content['date_label']) ?></div>
|
||||
</div>
|
||||
|
||||
<div class="content-item-daily">
|
||||
<div class="header-sticky-note"><div>Heutige Ansagen</div></div>
|
||||
<div class="news-container">
|
||||
<div class="news-scroll">
|
||||
<?php foreach ($announcements as $announcement): ?>
|
||||
<div class="news-box">
|
||||
<div class="news-date">
|
||||
<?= dashboard_html($announcement['date_label']) ?>
|
||||
<?php if ($announcement['end_label'] !== ''): ?>
|
||||
<br><?= dashboard_html($announcement['end_label']) ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div>
|
||||
<?= dashboard_html($announcement['title']) ?>
|
||||
<?php if ($announcement['subtitle'] !== ''): ?>
|
||||
<div class="news-subtitle"><?= dashboard_html($announcement['subtitle']) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($announcement['meta'] !== ''): ?>
|
||||
<div class="news-subtitle"><i><?= dashboard_html($announcement['meta']) ?></i></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<body class="dashboard">
|
||||
<main class="dashboard-main" aria-label="Gemeindeinformationen">
|
||||
<div class="dashboard-column announcements-column">
|
||||
<section class="dashboard-overview" aria-label="Datum, Uhrzeit und Temperaturen">
|
||||
<div class="dashboard-clock">
|
||||
<time id="clock" aria-label="Uhrzeit Europe/Berlin">–:–</time>
|
||||
<time id="date">Datum wird geladen</time>
|
||||
</div>
|
||||
|
||||
<div class="content-column-right-upper-box-left">
|
||||
<div class="content-item-middle-top">
|
||||
<div class="header-sticky-note"><div>Bibelwort</div></div>
|
||||
<div class="bible"><?= dashboard_html($service['bibelwort']) ?></div>
|
||||
<div class="verse"><?= dashboard_html($service['bibelvers']) ?></div>
|
||||
</div>
|
||||
|
||||
<div class="content-column-double-item">
|
||||
<div class="content-item">
|
||||
<div class="header-sticky-note"><div>Eingangslied</div></div>
|
||||
<div class="GB"><?= dashboard_html($service['eingang_nummer']) ?></div>
|
||||
<div class="GB-title"><?= dashboard_html($service['eingang_titel']) ?></div>
|
||||
</div>
|
||||
|
||||
<div class="content-item">
|
||||
<div class="header-sticky-note"><div>Temperatur</div></div>
|
||||
<div class="temperature">
|
||||
<div class="container"><div id="wetter"></div></div>
|
||||
<div class="container">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 60 60" width="64px" height="64px" baseProfile="basic" aria-hidden="true">
|
||||
<polygon fill="#66e1ff" points="46,22.11 46,24.42 43,26.15 43,34.24 25,44.63 14,38.28 14,35.97 12,34.81 10,35.97 7,34.24 7,20.38 4,20.96 4,18.65 22,8.26 34,5.95"/>
|
||||
<polygon fill="#4292a6" points="25,30.774 28,34.815 43,26.155 43,34.238 25,44.63"/>
|
||||
<polygon fill="#ff5757" points="16,18.649 4,20.959 4,18.649 16,16.34 28,32.506 28,34.815"/>
|
||||
<polygon fill="#4292a6" points="34,8.257 22,10.566 22,8.257 34,5.947 46,22.113 46,24.423"/>
|
||||
<polygon fill="#e64e4e" points="4,18.649 22,8.257 34,5.947 16,16.34"/>
|
||||
<polygon fill="#cc4545" points="28,32.506 46,22.113 34,5.947 16,16.34"/>
|
||||
<polygon fill="#b33d3d" points="28,32.506 46,22.113 46,24.423 28,34.815"/>
|
||||
<polygon fill="#4292a6" points="17,34.238 22,37.124 22,31.351 17,28.464"/>
|
||||
<polygon fill="#4292a6" points="10,35.97 12,34.815 12,27.887 10,26.732"/>
|
||||
<polygon fill="#4ad8ff" points="19,33.083 22,34.815 22,31.351 19,29.619"/>
|
||||
<polygon fill="#52b1cc" points="17,34.238 22,37.124 22,34.815 19,33.083"/>
|
||||
<polygon fill="#ff5757" points="12,34.815 14,35.97 14,29.041 12,27.887"/>
|
||||
</svg>
|
||||
<div class="text-indoor" id="temperature_txt"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-item-bottom-box">
|
||||
<div class="header-sticky-note"><div>Aushänge</div></div>
|
||||
<div class="aushang-flex">
|
||||
<?php if ($posters === []): ?>
|
||||
<p style="text-align: left">Keine Aushänge vorgeplant.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($posters as $poster): ?>
|
||||
<img src="<?= dashboard_html($poster['url']) ?>" alt="<?= dashboard_html($poster['alt']) ?>" height="95%">
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="temperatures">
|
||||
<div><dt>Innen</dt><dd id="indoor" class="is-unavailable">Wird geladen …</dd></div>
|
||||
<div><dt>Außen</dt><dd id="outdoor" class="is-unavailable">Wird geladen …</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="dashboard-panel text-panel" id="announcements-panel" aria-labelledby="announcements-title">
|
||||
<div class="panel-heading"><h1 id="announcements-title">Ansagen</h1></div>
|
||||
<div class="text-stage"><p class="empty-state">Ansagen werden geladen …</p></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="content-column-left">
|
||||
<div class="content-item-video">
|
||||
<div class="video-container" aria-label="Leer"></div>
|
||||
</div>
|
||||
|
||||
<div class="content-item-month">
|
||||
<div class="header-sticky-note"><div>Monatsplan</div></div>
|
||||
<div class="content-item-month-cell">
|
||||
<?php foreach ($monthImages as $image): ?>
|
||||
<img src="<?= dashboard_html($image['url']) ?>" data-month-page="<?= (int) $image['page'] ?>" alt="Monatsplan Seite <?= (int) $image['page'] ?>">
|
||||
<?php endforeach; ?>
|
||||
<div class="dashboard-column bible-column">
|
||||
<section class="dashboard-panel text-panel bible-panel" id="bible-panel" aria-labelledby="bible-title">
|
||||
<div class="panel-heading"><h2 id="bible-title">Bibelwort für heute</h2></div>
|
||||
<div class="text-stage"><p class="empty-state">Bibelwort wird geladen …</p></div>
|
||||
</section>
|
||||
<section class="dashboard-panel text-panel" id="departures-panel" aria-labelledby="departures-title">
|
||||
<div class="panel-heading">
|
||||
<h2 id="departures-title">Abfahrten: Eichborndamm/Antonienstr.</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-stage"><p class="empty-state">Abfahrten werden geladen …</p></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="dashboard-panel image-panel" id="month-panel" aria-labelledby="month-title">
|
||||
<div class="panel-heading"><h2 id="month-title">Monatsplan</h2></div>
|
||||
<div class="image-stage"><p class="empty-state">Monatsplan wird geladen …</p></div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-panel image-panel" id="poster-panel" aria-labelledby="poster-title">
|
||||
<div class="panel-heading">
|
||||
<h2 id="poster-title">Aushänge</h2>
|
||||
<svg class="poster-progress" viewBox="0 0 24 24" aria-hidden="true" hidden>
|
||||
<circle class="poster-progress-track" cx="12" cy="12" r="10" />
|
||||
<circle class="poster-progress-value" cx="12" cy="12" r="10" pathLength="100" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="image-stage"><p class="empty-state">Aushänge werden geladen …</p></div>
|
||||
</section>
|
||||
</main>
|
||||
<noscript>Bitte JavaScript aktivieren, damit Uhrzeit, Inhalte und Seitenwechsel angezeigt werden.</noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,7 +9,6 @@ date_default_timezone_set('Europe/Berlin');
|
||||
require_once __DIR__ . '/util/dashboard_helpers.php';
|
||||
|
||||
$resource = (string) ($_GET['resource'] ?? 'content');
|
||||
$pdo = null;
|
||||
|
||||
try {
|
||||
switch ($resource) {
|
||||
@@ -17,12 +16,22 @@ try {
|
||||
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
|
||||
$payload = dashboard_fetch_content($pdo, dashboard_now());
|
||||
break;
|
||||
case 'images':
|
||||
$payload = [
|
||||
'posters' => dashboard_list_posters(dashboard_now()),
|
||||
'month_images' => dashboard_month_images(),
|
||||
];
|
||||
break;
|
||||
case 'indoor':
|
||||
$payload = ['indoor' => dashboard_fetch_indoor_temperature()];
|
||||
break;
|
||||
case 'outdoor':
|
||||
$payload = ['outdoor' => dashboard_fetch_outdoor_weather()];
|
||||
break;
|
||||
case 'departures_eich':
|
||||
require_once __DIR__ . '/util/bvg.php';
|
||||
$payload = dashboard_fetch_departures();
|
||||
break;
|
||||
default:
|
||||
http_response_code(400);
|
||||
$payload = ['error' => 'Unbekannte Dashboard-Ressource.'];
|
||||
@@ -33,6 +42,4 @@ try {
|
||||
http_response_code(502);
|
||||
error_log('Dashboard data endpoint (' . $resource . '): ' . $exception->getMessage());
|
||||
echo json_encode(['error' => 'Dashboard-Daten konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE);
|
||||
} finally {
|
||||
$pdo = null;
|
||||
}
|
||||
|
||||
@@ -1,340 +1,124 @@
|
||||
/* Gesamtes Seiten-Layout */
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: 'SUSE', sans-serif;
|
||||
font-size: 20px;
|
||||
background-image: url('cloudy.jpg');
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Content Board Layout */
|
||||
.content-board {
|
||||
background-color: #a8a6a7a7;
|
||||
width: 99%;
|
||||
height: 97%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
border-radius: 0;
|
||||
padding: 0 6px 7px 3px;
|
||||
}
|
||||
|
||||
.content-column-left {
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.content-column-right {
|
||||
width: 58%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.temperature {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 5px 0 0 0;
|
||||
height: 70%;
|
||||
}
|
||||
|
||||
.verse {
|
||||
text-align: justify;
|
||||
color: rgb(67, 67, 67);
|
||||
padding: 5px 15px 5px 15px;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: rgb(67, 67, 67);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.content-column-right-upper-box-left {
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.content-column-right-upper-box-right {
|
||||
width: 39%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.content-column-right-upper-box {
|
||||
width: 100%;
|
||||
height: 51%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content-item-time {
|
||||
border-radius: 0;
|
||||
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
|
||||
background-color: #ffffffab;
|
||||
width: 100%;
|
||||
height: 30%;
|
||||
margin: 15px 0 15px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.content-item-news {
|
||||
border-radius: 0;
|
||||
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
|
||||
background-color: #ffffffab;
|
||||
width: 100%;
|
||||
height: 63%;
|
||||
margin: 15px 0 15px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.content-item-video {
|
||||
border-radius: 0;
|
||||
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
|
||||
background-color: #cbc5bc;
|
||||
width: 100%;
|
||||
height: 40%;
|
||||
margin: 15px 0 15px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.content-item-middle-top {
|
||||
position: relative;
|
||||
border-radius: 0;
|
||||
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
|
||||
background-color: #cbc5bc;
|
||||
width: 100%;
|
||||
height: 60%;
|
||||
margin: 15px 0 15px 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Pseudoelement für den animierten border-bottom Effekt */
|
||||
.content-item-middle-top::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 15px;
|
||||
background: linear-gradient(to bottom, transparent, #cbc5bc);
|
||||
}
|
||||
|
||||
/* Monatsplan: jetzt mit weißem Hintergrund */
|
||||
.content-item-month {
|
||||
border-radius: 0;
|
||||
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
|
||||
background-color: #cbc5bc7d;
|
||||
width: 100%;
|
||||
height: 56%;
|
||||
margin: 15px 0 15px 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
.content-item-month-cell {
|
||||
display: flex;
|
||||
background-color: #ffffffe5;
|
||||
justify-content: center; /* Zentriere die Bilder horizontal */
|
||||
height: 94%;
|
||||
align-items: center; /* Zentriere die Bilder vertikal */
|
||||
}
|
||||
|
||||
.content-item-month-cell img {
|
||||
max-width: 100%; /* Verhindert, dass das Bild größer als der Container wird */
|
||||
max-height: 100%; /* Verhindert Verzerrungen, indem es die maximale Höhe auf die Containerhöhe beschränkt */
|
||||
object-fit: contain; /* Stellt sicher, dass das Bild im Container bleibt, ohne gestreckt zu werden */
|
||||
}
|
||||
|
||||
.content-item-daily {
|
||||
border-radius: 0;
|
||||
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
|
||||
background-color: #edebe8;
|
||||
width: 100%;
|
||||
height: 63%;
|
||||
margin: 15px 0 15px 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header-sticky-note {
|
||||
font-size: 25px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
width: 100%;
|
||||
padding: 5px 0 15px 0;
|
||||
border-bottom: 2px solid #38312430;
|
||||
background-color: #cbc5bc;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 80px;
|
||||
text-align: center;
|
||||
padding: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.bible {
|
||||
text-align: center;
|
||||
font-size: 60px;
|
||||
padding: 10px 0 10px 0;
|
||||
border-bottom: 2px solid #38312456;
|
||||
background-color: #edebe8;
|
||||
}
|
||||
|
||||
.GB {
|
||||
text-align: center;
|
||||
font-size: 60px;
|
||||
padding: 5px 0 0 0;
|
||||
}
|
||||
|
||||
.GB-title {
|
||||
text-align: center;
|
||||
color: rgb(67, 67, 67);
|
||||
}
|
||||
|
||||
.content-column-double-item {
|
||||
width: 100%;
|
||||
height: 33%;
|
||||
margin: 15px 0 15px 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Elemente innerhalb des Content Boards */
|
||||
.content-item {
|
||||
border-radius: 0;
|
||||
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
|
||||
background-color: #edebe8;
|
||||
height: 100%;
|
||||
width: 49%;
|
||||
}
|
||||
|
||||
.content-item-bottom-box {
|
||||
border-radius: 0;
|
||||
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
|
||||
background-color: #edebe8;
|
||||
margin: 6px 0 0 0;
|
||||
height: 47.5%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
border-radius: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 5px solid #cbc5bc;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.video-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: scale(1.2);
|
||||
object-fit: cover;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 40%;
|
||||
background-color: #38312431;
|
||||
border-radius: 0;
|
||||
border: 2px solid #38312440;
|
||||
}
|
||||
|
||||
.text-outdoor {
|
||||
font-weight: bold;
|
||||
padding: 9px 0 0 0;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.text-indoor {
|
||||
font-weight: bold;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.news-box {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 5px 15px 5px 15px;
|
||||
border-bottom: 2px solid #38312456;
|
||||
background-color: #edebe8;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.news-date {
|
||||
font-weight: bold;
|
||||
margin: 0 10px 0 0;
|
||||
}
|
||||
|
||||
.news-subtitle {
|
||||
color: rgba(67, 67, 67, 0.657);
|
||||
}
|
||||
|
||||
.news-container {
|
||||
height: 290px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.news-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation-name: scroll-v;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
--scroll-distance-v: 0px;
|
||||
}
|
||||
@keyframes scroll-v {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(var(--scroll-distance-v)); }
|
||||
}
|
||||
|
||||
.aushang-flex img {
|
||||
border-radius: 0;
|
||||
margin: 0 10px 0 0;
|
||||
border: 2px solid #38312440;
|
||||
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.aushang-flex {
|
||||
padding: 0 5px 0 5px;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
height: 90.5%;
|
||||
border-radius: 0;
|
||||
|
||||
animation-name: scroll-h;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
--scroll-distance-h: 0px;
|
||||
}
|
||||
@keyframes scroll-h {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(var(--scroll-distance-h)); }
|
||||
}
|
||||
/* Bildgrößen aus dem Original; Schrift und Farben aus den zentralen CD-Tokens. */
|
||||
body.dashboard {
|
||||
--dashboard-text: var(--nak-black);
|
||||
--dashboard-meta: var(--gray-700);
|
||||
--panel-surface: #fff;
|
||||
--dashboard-unit: clamp(1px, 0.052083333vw, 2px);
|
||||
--panel-heading-height: calc(31 * var(--dashboard-unit));
|
||||
/* Original: Board 97vh minus 7px Innenabstand, Monatsfeld 56%, Bildbereich 94%. */
|
||||
--original-board-height: calc(97vh - 7px);
|
||||
--month-image-height: calc(var(--original-board-height) * 0.56 * 0.94);
|
||||
--month-aspect: 1.414;
|
||||
--announcement-font: var(--bible-font);
|
||||
--announcement-detail-font: calc(20 * var(--dashboard-unit));
|
||||
--announcement-meta-font: calc(15 * var(--dashboard-unit));
|
||||
--announcement-leading: 1.35;
|
||||
--bible-font: calc(23 * var(--dashboard-unit));
|
||||
display: block;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
color: var(--dashboard-text);
|
||||
background: var(--nak-blue-light) url('cloudy.jpg') center / cover no-repeat;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dashboard [hidden] { display: none !important; }
|
||||
.dashboard-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.05fr) calc(var(--month-image-height) * var(--month-aspect));
|
||||
grid-template-rows: calc(var(--month-image-height) + var(--panel-heading-height)) minmax(0, 1fr);
|
||||
grid-template-areas: "announcements bible month" "posters posters posters";
|
||||
gap: var(--space-sm);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
#month-panel { grid-area: month; --panel-accent: var(--nak-purple); }
|
||||
#poster-panel { grid-area: posters; --panel-accent: var(--nak-orange); --poster-frame-width: 2px; }
|
||||
#announcements-panel { --panel-accent: var(--nak-teal); }
|
||||
.dashboard-column { display: grid; gap: var(--space-sm); min-height: 0; min-width: 0; }
|
||||
.announcements-column { grid-area: announcements; grid-template-rows: auto minmax(0, 1fr); }
|
||||
.bible-column { grid-area: bible; grid-template-rows: minmax(0, 1fr) auto; }
|
||||
.dashboard-panel { min-width: 0; min-height: 0; background: var(--panel-surface); }
|
||||
.dashboard-overview { display: flex; align-items: flex-end; justify-content: space-between; gap: calc(8 * var(--dashboard-unit)); padding: calc(16 * var(--dashboard-unit)) calc(12 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)); background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--nak-blue); }
|
||||
.dashboard-clock { display: grid; flex: 0 0 auto; justify-items: start; gap: calc(4 * var(--dashboard-unit)); }
|
||||
#clock, #date { width: max-content; }
|
||||
#clock { font-size: calc(64 * var(--dashboard-unit)); line-height: 0.9; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
#date { font-size: var(--announcement-meta-font); white-space: nowrap; }
|
||||
.temperatures { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); min-width: 0; gap: calc(8 * var(--dashboard-unit)); margin: 0; }
|
||||
.temperatures > div { min-width: 0; }
|
||||
.temperatures dt { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); }
|
||||
.temperatures dd { margin: 0; font-size: var(--announcement-detail-font); line-height: var(--announcement-leading); font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.temperatures .is-unavailable { font-size: calc(12 * var(--dashboard-unit)); font-weight: 300; color: var(--dashboard-meta); }
|
||||
.image-panel, .text-panel { display: grid; grid-template-rows: var(--panel-heading-height) minmax(0, 1fr); }
|
||||
.panel-heading { display: flex; align-items: center; padding: calc(3 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)) 0; background: var(--panel-surface); background: color-mix(in srgb, var(--panel-accent) 12%, white); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--panel-accent); }
|
||||
.panel-heading h1, .panel-heading h2 { margin: 0; color: var(--dashboard-text); font-size: calc(20 * var(--dashboard-unit)); line-height: 1.1; font-weight: 500; }
|
||||
.poster-progress { width: calc(18 * var(--dashboard-unit)); height: calc(18 * var(--dashboard-unit)); margin-left: auto; flex: 0 0 auto; fill: none; stroke-width: 2; }
|
||||
.poster-progress-track { stroke: var(--gray-300); }
|
||||
.poster-progress-value { stroke: var(--panel-accent); stroke-dasharray: 100; stroke-dashoffset: 100; transform: rotate(-90deg); transform-origin: center; }
|
||||
.image-stage { min-height: 0; min-width: 0; padding: 0; column-gap: var(--space-sm); }
|
||||
#month-panel .image-stage,
|
||||
#poster-panel .image-stage { padding: calc(4 * var(--dashboard-unit)); }
|
||||
.image-sheet { display: flex; justify-content: center; align-items: center; gap: var(--space-sm); width: 100%; height: 100%; min-height: 0; }
|
||||
#month-panel .image-sheet { align-items: flex-start; gap: calc(4 * var(--dashboard-unit)); }
|
||||
.image-cell { margin: 0; min-height: 0; min-width: 0; flex: 0 0 auto; }
|
||||
.poster-document { display: flex; flex: 0 0 auto; gap: 0; background: #fff; border: var(--poster-frame-width) solid var(--gray-500); }
|
||||
.image-cell img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||
.image-cell.is-cropped { position: relative; overflow: hidden; }
|
||||
.image-cell.is-cropped img { position: absolute; max-width: none; }
|
||||
.image-cell .empty-state { padding: var(--space-sm); font-size: var(--announcement-font); }
|
||||
.text-stage { min-height: 0; margin: calc(6 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)); overflow: hidden; position: relative; }
|
||||
#announcements-panel .text-stage { margin: 0; }
|
||||
#announcements-panel .text-stage > .empty-state { padding: 0 calc(10 * var(--dashboard-unit)); }
|
||||
.text-page { color: var(--dashboard-text); }
|
||||
.text-page p { margin: 0 0 var(--space-xs); white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--announcement-font); line-height: var(--announcement-leading); }
|
||||
.text-page p:last-child { margin-bottom: 0; }
|
||||
.text-page .announcement-title, .text-page .bible-reference { font-weight: 500; }
|
||||
.text-page .announcement-subtitle { font-size: var(--announcement-detail-font); color: var(--dashboard-meta); }
|
||||
.text-page .announcement-meta { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); }
|
||||
.announcement-list { max-width: none; margin: 0; padding: 0; list-style: none; }
|
||||
/* Originale Zeilenaufteilung: Datum links, Inhalt mit Zeit/Ort rechts. */
|
||||
.announcement-entry { display: grid; grid-template-columns: calc(100 * var(--dashboard-unit)) minmax(0, 1fr); gap: calc(12 * var(--dashboard-unit)); padding: calc(6 * var(--dashboard-unit)) calc(10 * var(--dashboard-unit)); border-bottom: 1px solid var(--gray-300); background: var(--panel-surface); }
|
||||
.announcement-entry:nth-child(even) { background: var(--gray-50); }
|
||||
.announcement-content { min-width: 0; }
|
||||
.announcement-entry p { margin: 0; }
|
||||
.announcement-content .announcement-meta { font-style: italic; }
|
||||
.announcement-when { min-width: 0; font-weight: 300; color: var(--dashboard-meta); font-variant-numeric: tabular-nums; }
|
||||
.announcement-when p { margin-bottom: 0; font-size: var(--announcement-detail-font); }
|
||||
.bible-panel { --panel-accent: var(--nak-yellow); }
|
||||
.bible-panel .text-stage { margin-inline: calc(24 * var(--dashboard-unit)); margin-top: calc(12 * var(--dashboard-unit)); }
|
||||
.bible-panel .text-page { max-width: none; }
|
||||
#departures-panel { --panel-accent: var(--nak-darkblue); height: calc(161 * var(--dashboard-unit)); }
|
||||
#departures-panel .text-stage { margin: 0; }
|
||||
#departures-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(18 * var(--dashboard-unit)); }
|
||||
.departures-list { max-width: none; margin: 0; padding: 0; list-style: none; }
|
||||
.departure-row { --departure-color: var(--gray-500); display: grid; grid-template-columns: calc(38 * var(--dashboard-unit)) minmax(0, 1fr) calc(52 * var(--dashboard-unit)) calc(80 * var(--dashboard-unit)); align-items: center; gap: calc(6 * var(--dashboard-unit)); height: calc(26 * var(--dashboard-unit)); padding: 0 calc(5 * var(--dashboard-unit)) 0 calc(8 * var(--dashboard-unit)); border-right: calc(6 * var(--dashboard-unit)) solid var(--departure-color); background: var(--panel-surface); font-size: var(--announcement-meta-font); line-height: 1.2; }
|
||||
.departure-row:nth-child(even) { background: var(--gray-50); }
|
||||
.departure-row.is-ontime, .departure-row.is-scheduled { --departure-color: var(--nak-darkgreen); }
|
||||
.departure-row.is-delayed { --departure-color: var(--nak-orange); }
|
||||
.departure-row.is-early, .departure-row.is-cancelled { --departure-color: var(--nak-red); }
|
||||
.departure-line { font-weight: 500; }
|
||||
.departure-direction { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.departure-remaining, .departure-status { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
#departures-panel .empty-state { padding: calc(8 * var(--dashboard-unit)); font-size: var(--announcement-meta-font); }
|
||||
.text-stage.is-overflowing::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
height: calc(24 * var(--dashboard-unit));
|
||||
background: linear-gradient(to bottom, transparent, var(--panel-surface));
|
||||
pointer-events: none;
|
||||
}
|
||||
.empty-state { margin: 0; font-size: var(--announcement-font); line-height: var(--announcement-leading); color: var(--dashboard-meta); }
|
||||
.image-stage > .empty-state { padding: var(--space-md); }
|
||||
.dashboard noscript { position: fixed; bottom: 0; padding: var(--space-lg); background: #fff; color: var(--nak-red); }
|
||||
@media (max-width: 999px), (max-height: 599px) {
|
||||
body.dashboard { height: auto; min-height: 100vh; overflow: visible; }
|
||||
.dashboard-main {
|
||||
height: auto;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas: "announcements" "bible" "month" "posters";
|
||||
grid-template-rows: 520px 520px 65vh 55vh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
const DASHBOARD_BVG_STOP = '900086103';
|
||||
|
||||
function dashboard_fetch_departures(): array
|
||||
{
|
||||
$directory = __DIR__ . '/../cache';
|
||||
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException('BVG-Cache nicht beschreibbar.');
|
||||
}
|
||||
$file = fopen($directory . '/bvg-' . DASHBOARD_BVG_STOP . '.json', 'c+');
|
||||
if (!$file) {
|
||||
throw new RuntimeException('BVG-Cache nicht verfügbar.');
|
||||
}
|
||||
try {
|
||||
// Gemeinsamer Cache: alle 12 Sekunden und zusätzlich zum Minutenwechsel.
|
||||
if (!flock($file, LOCK_EX)) {
|
||||
throw new RuntimeException('BVG-Cache konnte nicht gesperrt werden.');
|
||||
}
|
||||
$cache = json_decode(stream_get_contents($file), true) ?: [];
|
||||
if (microtime(true) >= ($cache['retryAt'] ?? 0)) {
|
||||
try {
|
||||
$data = dashboard_http_json('https://v6.bvg.transport.rest/stops/' . DASHBOARD_BVG_STOP . '/departures?'
|
||||
. 'duration=120&results=30&bus=true&suburban=false&subway=false&tram=false'
|
||||
. '&ferry=false&express=false®ional=false&remarks=false&language=de&pretty=false');
|
||||
if (!isset($data['departures']) || !is_array($data['departures'])) {
|
||||
throw new RuntimeException('Unerwartete BVG-Antwort.');
|
||||
}
|
||||
$now = microtime(true);
|
||||
$cache = ['departures' => array_values(array_filter($data['departures'], static fn(array $d): bool =>
|
||||
($d['line']['product'] ?? '') === 'bus'
|
||||
&& in_array($d['stop']['id'] ?? '', [DASHBOARD_BVG_STOP, '900000' . substr(DASHBOARD_BVG_STOP, 3)], true))),
|
||||
'updatedAt' => (int) round($now * 1000), 'retryAt' => min($now + 12, (floor($now / 60) + 1) * 60),
|
||||
'unavailable' => false];
|
||||
} catch (Throwable $error) {
|
||||
// Letzte Antwort behalten; auch bei 429/503 nicht sofort erneut anfragen.
|
||||
error_log('Dashboard BVG: ' . $error->getMessage());
|
||||
$cache['unavailable'] = true;
|
||||
$cache['retryAt'] = microtime(true) + 30;
|
||||
}
|
||||
rewind($file);
|
||||
$json = json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
if (!ftruncate($file, 0) || fwrite($file, $json) !== strlen($json) || !fflush($file)) {
|
||||
throw new RuntimeException('BVG-Cache konnte nicht gespeichert werden.');
|
||||
}
|
||||
}
|
||||
return $cache + ['departures' => [], 'updatedAt' => 0];
|
||||
} finally {
|
||||
fclose($file);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../../contentpanel/insertion/util/contentpanel_helpers.php';
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../contentpanel/insertion/util/news.php';
|
||||
require_once __DIR__ . '/../../contentpanel/insertion/util/announcements.php';
|
||||
require_once __DIR__ . '/../../../general_files/ini/runtime_env.php';
|
||||
|
||||
const DASHBOARD_TIMEZONE = 'Europe/Berlin';
|
||||
const DASHBOARD_HA_ENTITY_ID = 'sensor.temp_temperatur';
|
||||
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571291';
|
||||
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.310422';
|
||||
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571524';
|
||||
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.312494';
|
||||
const DASHBOARD_NEWS_URL = '/intranet_repo/projects/contentpanel/insertion/news/png/';
|
||||
|
||||
function dashboard_now(): DateTimeImmutable
|
||||
@@ -14,141 +17,151 @@ function dashboard_now(): DateTimeImmutable
|
||||
return new DateTimeImmutable('now', new DateTimeZone(DASHBOARD_TIMEZONE));
|
||||
}
|
||||
|
||||
function dashboard_html($value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function dashboard_fetch_content(PDO $pdo, DateTimeImmutable $now): array
|
||||
{
|
||||
$now = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE));
|
||||
$today = $now->format('Y-m-d');
|
||||
$payload = ['date' => $today, 'service' => null, 'announcements' => [], 'errors' => []];
|
||||
|
||||
$serviceStatement = $pdo->prepare(
|
||||
'SELECT bibelwort, bibelvers, eingang_nummer, eingang_titel
|
||||
FROM gottesdienst
|
||||
WHERE bibeldatum = ?'
|
||||
);
|
||||
$serviceStatement->execute([$today]);
|
||||
$service = $serviceStatement->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
$announcementStatement = $pdo->prepare(
|
||||
'SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort, ansage_titel, ansage_untertitel
|
||||
FROM ansagen
|
||||
WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ?
|
||||
ORDER BY ansage_datum_von ASC'
|
||||
);
|
||||
$announcementStatement->execute([$today, $today]);
|
||||
$announcements = $announcementStatement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($announcements as &$announcement) {
|
||||
$announcement = dashboard_prepare_announcement($announcement, $now);
|
||||
// Unabhängige Abfragen: ein fehlerhafter Bereich blockiert den anderen nicht.
|
||||
try {
|
||||
$statement = $pdo->prepare(
|
||||
'SELECT bibelwort, bibelvers FROM gottesdienst
|
||||
WHERE bibeldatum = ? ORDER BY id ASC LIMIT 1'
|
||||
);
|
||||
$statement->execute([$today]);
|
||||
$service = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
if ($service) {
|
||||
// Leitgedanken-Import: bibelwort = Referenz, bibelvers = eigentlicher Text.
|
||||
$payload['service'] = [
|
||||
'reference' => trim((string) ($service['bibelwort'] ?? '')),
|
||||
'text' => trim((string) ($service['bibelvers'] ?? '')),
|
||||
];
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Dashboard Bibelwort: ' . $exception->getMessage());
|
||||
$payload['errors'][] = 'service';
|
||||
}
|
||||
unset($announcement);
|
||||
|
||||
contentpanel_cleanup_expired_news();
|
||||
try {
|
||||
$statement = $pdo->prepare(
|
||||
'SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort,
|
||||
ansage_titel, ansage_untertitel
|
||||
FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) >= ?
|
||||
ORDER BY ansage_datum_von ASC NULLS LAST, ansage_uhrzeit ASC NULLS LAST, id ASC'
|
||||
);
|
||||
contentpanel_cleanup_expired_announcements($pdo, $today);
|
||||
$statement->execute([$today]);
|
||||
foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$payload['announcements'][] = dashboard_prepare_announcement($row, $now);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Dashboard Ansagen: ' . $exception->getMessage());
|
||||
$payload['errors'][] = 'announcements';
|
||||
}
|
||||
|
||||
return [
|
||||
'date_label' => dashboard_date_label($now),
|
||||
'service' => [
|
||||
'bibelwort' => (string) ($service['bibelwort'] ?? 'Bibelwort nicht eingetragen'),
|
||||
'bibelvers' => (string) ($service['bibelvers'] ?? 'Bibelvers nicht gefunden.'),
|
||||
'eingang_nummer' => (string) ($service['eingang_nummer'] ?? 'N/A'),
|
||||
'eingang_titel' => (string) ($service['eingang_titel'] ?? 'Eingangslied nicht gefunden'),
|
||||
],
|
||||
'announcements' => $announcements,
|
||||
'posters' => dashboard_list_posters(),
|
||||
'month_images' => dashboard_month_images(),
|
||||
];
|
||||
return $payload;
|
||||
}
|
||||
|
||||
function dashboard_date_label(DateTimeImmutable $date): string
|
||||
function dashboard_date(string $raw): ?DateTimeImmutable
|
||||
{
|
||||
static $months = [
|
||||
1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
];
|
||||
|
||||
return sprintf('%02d. %s', (int) $date->format('d'), $months[(int) $date->format('n')]);
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw, new DateTimeZone(DASHBOARD_TIMEZONE));
|
||||
return $date && $date->format('Y-m-d') === $raw ? $date : null;
|
||||
}
|
||||
|
||||
function dashboard_prepare_announcement(array $announcement, DateTimeImmutable $now): array
|
||||
{
|
||||
$startRaw = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10);
|
||||
$endRaw = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10);
|
||||
$timeRaw = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5);
|
||||
|
||||
$startDate = DateTimeImmutable::createFromFormat('!Y-m-d', $startRaw, $now->getTimezone());
|
||||
$tomorrow = $now->setTime(0, 0)->modify('+1 day');
|
||||
|
||||
if ($startDate && $startDate->format('Y-m-d') === $now->format('Y-m-d')) {
|
||||
$dateLabel = 'Heute';
|
||||
} elseif ($startDate && $startDate->format('Y-m-d') === $tomorrow->format('Y-m-d')) {
|
||||
$dateLabel = 'Morgen';
|
||||
} elseif ($startDate) {
|
||||
static $weekdays = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
$dateLabel = $weekdays[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.');
|
||||
$now = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE));
|
||||
$start = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10);
|
||||
$end = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10);
|
||||
$today = $now->format('Y-m-d');
|
||||
$startDate = dashboard_date($start);
|
||||
$endDate = dashboard_date($end);
|
||||
if ($start === $today) {
|
||||
$label = 'Heute';
|
||||
} elseif ($start === $now->modify('+1 day')->format('Y-m-d')) {
|
||||
$label = 'Morgen';
|
||||
} else {
|
||||
$dateLabel = $startRaw;
|
||||
$days = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
$label = $startDate ? $days[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.') : '';
|
||||
}
|
||||
|
||||
$endLabel = '';
|
||||
if ($endRaw !== '') {
|
||||
$endDate = DateTimeImmutable::createFromFormat('!Y-m-d', $endRaw, $now->getTimezone());
|
||||
$endLabel = $endDate ? 'bis ' . $endDate->format('d.m.') : 'bis ' . $endRaw;
|
||||
}
|
||||
|
||||
$meta = [];
|
||||
if ($timeRaw !== '') {
|
||||
$meta[] = $timeRaw . ' Uhr';
|
||||
}
|
||||
$endLabel = $endDate && $end !== $start ? 'bis ' . $endDate->format('d.m.') : '';
|
||||
$time = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5);
|
||||
$place = trim((string) ($announcement['ansage_ort'] ?? ''));
|
||||
if ($place !== '') {
|
||||
$meta[] = $place;
|
||||
}
|
||||
|
||||
return [
|
||||
'date_label' => $dateLabel,
|
||||
'date_label' => $label,
|
||||
'end_label' => $endLabel,
|
||||
'title' => (string) ($announcement['ansage_titel'] ?? ''),
|
||||
'subtitle' => (string) ($announcement['ansage_untertitel'] ?? ''),
|
||||
'meta' => implode(', ', $meta),
|
||||
'time' => $time,
|
||||
'place' => $place,
|
||||
'title' => trim((string) ($announcement['ansage_titel'] ?? '')),
|
||||
'subtitle' => trim((string) ($announcement['ansage_untertitel'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
function dashboard_list_posters(): array
|
||||
function dashboard_image(string $path, string $url): ?array
|
||||
{
|
||||
$files = glob(CONTENTPANEL_NEWS_PNG_DIRECTORY . '/*.png') ?: [];
|
||||
natsort($files);
|
||||
|
||||
$posters = [];
|
||||
foreach ($files as $file) {
|
||||
if (!is_file($file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$basename = basename($file);
|
||||
$posters[] = [
|
||||
'url' => DASHBOARD_NEWS_URL . rawurlencode($basename) . '?v=' . (int) filemtime($file),
|
||||
'alt' => 'Aushang',
|
||||
];
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_values($posters);
|
||||
$size = @getimagesize($path);
|
||||
if (!$size || $size[0] < 1 || $size[1] < 1) {
|
||||
return null;
|
||||
}
|
||||
// Neu erzeugte, aber identische PNGs dürfen keinen sichtbaren Austausch auslösen.
|
||||
$version = @hash_file('sha256', $path);
|
||||
if ($version === false) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'url' => $url . '?v=' . $version,
|
||||
'width' => $size[0],
|
||||
'height' => $size[1],
|
||||
];
|
||||
}
|
||||
|
||||
function dashboard_month_images(): array
|
||||
function dashboard_list_posters(DateTimeImmutable $now, string $directory = CONTENTPANEL_NEWS_PNG_DIRECTORY): array
|
||||
{
|
||||
$today = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE))->format('Y-m-d');
|
||||
$files = glob($directory . '/*.png') ?: [];
|
||||
natsort($files);
|
||||
$posters = [];
|
||||
foreach ($files as $file) {
|
||||
$name = basename($file);
|
||||
if (!preg_match('/^(.+)_page_([1-9][0-9]*)\\.png$/D', $name, $match)
|
||||
|| !contentpanel_is_valid_news_file_base($match[1])) {
|
||||
continue;
|
||||
}
|
||||
// Dieselbe inklusive Ablauffrist wie im Contentpanel. Nur filtern, nicht löschen.
|
||||
$expiry = substr($match[1], 0, 10);
|
||||
if (!dashboard_date($expiry) || $expiry < $today) {
|
||||
continue;
|
||||
}
|
||||
$image = dashboard_image($file, DASHBOARD_NEWS_URL . rawurlencode($name));
|
||||
if ($image) {
|
||||
$title = str_replace('_', ' ', substr($match[1], 11));
|
||||
$posters[] = $image + [
|
||||
'document' => $match[1],
|
||||
'expires' => $expiry, 'page' => (int) $match[2],
|
||||
'alt' => $title . ' · Seite ' . (int) $match[2],
|
||||
];
|
||||
}
|
||||
}
|
||||
return $posters;
|
||||
}
|
||||
|
||||
function dashboard_month_images(?string $directory = null): array
|
||||
{
|
||||
$directory ??= __DIR__ . '/../month';
|
||||
$images = [];
|
||||
foreach ([1, 2] as $page) {
|
||||
$filename = 'terminuebersicht-' . $page . '.png';
|
||||
$path = __DIR__ . '/../month/' . $filename;
|
||||
$version = is_file($path) ? (int) filemtime($path) : 0;
|
||||
$images[] = [
|
||||
'page' => $page,
|
||||
'url' => 'month/' . $filename . '?v=' . $version,
|
||||
];
|
||||
$image = dashboard_image($directory . '/' . $filename, 'month/' . $filename);
|
||||
if ($image) {
|
||||
$images[] = $image + ['alt' => 'Monatsplan · Seite ' . $page];
|
||||
}
|
||||
}
|
||||
|
||||
return $images;
|
||||
}
|
||||
|
||||
@@ -159,17 +172,12 @@ function dashboard_fetch_indoor_temperature(): array
|
||||
$url = $baseUrl . '/api/states/' . rawurlencode(DASHBOARD_HA_ENTITY_ID);
|
||||
$data = dashboard_http_json($url, ['Authorization: Bearer ' . $token]);
|
||||
|
||||
if (!array_key_exists('state', $data)) {
|
||||
if (!isset($data['state']) || !is_numeric($data['state'])) {
|
||||
throw new RuntimeException('Unerwartete Home-Assistant-Antwort.');
|
||||
}
|
||||
|
||||
$value = $data['state'];
|
||||
if (is_numeric($value)) {
|
||||
$value = round((float) $value, 1);
|
||||
}
|
||||
|
||||
return [
|
||||
'value' => $value,
|
||||
'value' => (int) ceil((float) $data['state'] - 0.4),
|
||||
'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'),
|
||||
];
|
||||
}
|
||||
@@ -186,7 +194,6 @@ function dashboard_fetch_outdoor_weather(): array
|
||||
|
||||
$data = dashboard_http_json('https://api.openweathermap.org/data/2.5/weather?' . $query);
|
||||
$temperature = $data['main']['temp'] ?? null;
|
||||
$icon = (string) ($data['weather'][0]['icon'] ?? '');
|
||||
|
||||
if (!is_numeric($temperature)) {
|
||||
throw new RuntimeException('Unerwartete OpenWeather-Antwort.');
|
||||
@@ -194,27 +201,9 @@ function dashboard_fetch_outdoor_weather(): array
|
||||
|
||||
return [
|
||||
'temperature' => (int) round((float) $temperature),
|
||||
'icon' => dashboard_weather_kind($icon),
|
||||
];
|
||||
}
|
||||
|
||||
function dashboard_weather_kind(string $icon): string
|
||||
{
|
||||
return match ($icon) {
|
||||
'04d', '04n', '03d', '03n' => 'cloudy',
|
||||
'02d' => 'cloudy_day',
|
||||
'02n' => 'cloudy_night',
|
||||
'01d' => 'sun',
|
||||
'01n' => 'moon',
|
||||
'09d', '09n', '10n' => 'rain_shower',
|
||||
'10d' => 'rain',
|
||||
'11d', '11n' => 'thunder',
|
||||
'13d', '13n' => 'snowy',
|
||||
'50d', '50n' => 'mist',
|
||||
default => 'cloudy',
|
||||
};
|
||||
}
|
||||
|
||||
function dashboard_http_json(string $url, array $headers = []): array
|
||||
{
|
||||
$handle = curl_init($url);
|
||||
|
||||
@@ -125,15 +125,28 @@ class WebPreviewTests(unittest.TestCase):
|
||||
self.page.screenshot(path=str(Path(output) / f'web-{width}.png'), full_page=True, animations='disabled')
|
||||
self.assertEqual(self.page.locator('script:not([src])').count(), 0)
|
||||
|
||||
def test_permissions_and_csrf_reject_mutations(self):
|
||||
def test_permissions_and_csrf_reject_mutations(self):
|
||||
denied = self.page.request.post(self.url + '?deny=1', form={'action': 'delete_news', 'file_base': '2030-12-31-Mehrseitig'}, max_redirects=0)
|
||||
self.assertEqual(denied.status, 303)
|
||||
self.assertIn('/intranet/index.php', denied.headers['location'])
|
||||
self.page.request.post(self.url, form={'action': 'delete_news', 'file_base': '2030-12-31-Mehrseitig', 'csrf_token': 'wrong'}, max_redirects=0)
|
||||
self.page.reload()
|
||||
self.assertIn('Ungültige Anfrage', self.page.locator('.sb-alert-error').inner_text())
|
||||
self.assertTrue(all((self.news / f'2030-12-31-Mehrseitig_page_{n}.png').is_file() for n in range(1, 11)))
|
||||
|
||||
self.assertTrue(all((self.news / f'2030-12-31-Mehrseitig_page_{n}.png').is_file() for n in range(1, 11)))
|
||||
|
||||
def test_page_cleans_expired_announcements_on_each_authorized_request(self):
|
||||
log = self.root / 'cleanup.jsonl'
|
||||
before = len(log.read_text().splitlines())
|
||||
self.page.reload()
|
||||
cleanup = [json.loads(line) for line in log.read_text().splitlines()]
|
||||
self.assertEqual(len(cleanup), before + 1)
|
||||
sql, params = cleanup[-1]
|
||||
self.assertEqual(sql, 'DELETE FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?')
|
||||
self.assertEqual(len(params), 1)
|
||||
self.assertRegex(params[0], r'^\d{4}-\d{2}-\d{2}$')
|
||||
self.page.request.get(self.url + '?deny=1', max_redirects=0)
|
||||
self.assertEqual(len(log.read_text().splitlines()), len(cleanup))
|
||||
|
||||
def test_forms_preserve_fields_validation_and_bound_sql_parameters(self):
|
||||
for tab, action, fields in (
|
||||
('gottesdienst', 'create_service', {'bibeldatum': '2030-12-31', 'bibelwort': 'Joh 3,16', 'bibelvers': 'Text & "Zitat"'}),
|
||||
|
||||
@@ -8,8 +8,10 @@ class TestStatement extends PDOStatement
|
||||
public function __construct(private string $sql) {}
|
||||
public function execute(?array $params = null): bool
|
||||
{
|
||||
if (!str_starts_with($this->sql, 'SELECT')) {
|
||||
file_put_contents($_SERVER['DOCUMENT_ROOT'] . '/writes.jsonl', json_encode([$this->sql, $params]) . "\n", FILE_APPEND);
|
||||
if (!str_starts_with($this->sql, 'SELECT')) {
|
||||
$log = str_contains($this->sql, 'WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?')
|
||||
? '/cleanup.jsonl' : '/writes.jsonl';
|
||||
file_put_contents($_SERVER['DOCUMENT_ROOT'] . $log, json_encode([$this->sql, $params]) . "\n", FILE_APPEND);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -61,12 +61,11 @@ def has_style(value, declaration):
|
||||
|
||||
|
||||
def event_rows(cell):
|
||||
blocks = cell.find_all("div", class_="calEventBox")
|
||||
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
|
||||
|
||||
for index, title in enumerate(titles):
|
||||
time_text, place = "", "--"
|
||||
block = blocks[index] if index < len(blocks) else None
|
||||
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
|
||||
|
||||
for title in titles:
|
||||
time_text = ""
|
||||
block = title.find_parent("div", class_="calEventBox")
|
||||
details = block and block.find(
|
||||
style=lambda value: has_style(value, "font-weight:normal")
|
||||
)
|
||||
@@ -74,9 +73,10 @@ def event_rows(cell):
|
||||
parts = [part.strip() for part in details.stripped_strings if part.strip()]
|
||||
if parts and ":" in parts[0]:
|
||||
time_text = parts.pop(0)
|
||||
if parts:
|
||||
place = parts[0].lstrip(", ") or "--"
|
||||
yield time_text, title.get_text(strip=True), place
|
||||
# Nur das Ortsfeld auswerten, nicht Trennzeichen, Datumsbereiche oder Notizen.
|
||||
location = block and block.find(attrs={"name": "show_place"})
|
||||
place = location.get_text(" ", strip=True).lstrip(", ") if location else ""
|
||||
yield time_text, title.get_text(strip=True), "" if place == "--" else place
|
||||
|
||||
|
||||
def next_month(month):
|
||||
@@ -204,10 +204,14 @@ def add_month(pdf, cells, month, today=None):
|
||||
|
||||
|
||||
def add_month_section(pdf, cells, month, now, today=None, planning_note=False):
|
||||
name = MONTHS[month.month - 1]
|
||||
pdf.set_font("helvetica", "B", 16)
|
||||
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
|
||||
pdf.set_font("helvetica", "", 10)
|
||||
name = MONTHS[month.month - 1]
|
||||
pdf.set_font("helvetica", "B", 16)
|
||||
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
|
||||
logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
|
||||
if not os.path.isfile(logo):
|
||||
raise RuntimeError(f"Logo fehlt: {logo}")
|
||||
pdf.image(logo, 188, pdf.get_y() - 7.5, 11)
|
||||
pdf.set_font("helvetica", "", 10)
|
||||
subtitle = (
|
||||
f"Die Termine für {name} befinden sich noch in Planung und können sich ändern."
|
||||
if planning_note else "der Gemeinde Berlin-Reinickendorf"
|
||||
@@ -230,14 +234,11 @@ def create_pdf(cells, output_path, now=None):
|
||||
pdf.set_auto_page_break(auto=True, margin=15)
|
||||
pdf.add_page()
|
||||
|
||||
logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
|
||||
if not os.path.isfile(logo):
|
||||
raise RuntimeError(f"Logo fehlt: {logo}")
|
||||
pdf.image(logo, 188, 9.5, 11)
|
||||
|
||||
for index, month in enumerate(months):
|
||||
if index:
|
||||
pdf.cell(0, 6, " ", 0, 1)
|
||||
for index, month in enumerate(months):
|
||||
if index and pdf.page_no() == 1:
|
||||
pdf.add_page()
|
||||
elif index:
|
||||
pdf.cell(0, 6, " ", 0, 1)
|
||||
add_month_section(
|
||||
pdf, cells, month, now,
|
||||
today=today if index == 0 else None,
|
||||
|
||||
Reference in New Issue
Block a user