Feature/dashboard rewrite v2 #27
@@ -23,6 +23,7 @@ web/intranet_repo/projects/contentpanel/insertion/news/*
|
|||||||
|
|
||||||
# Generierte Dashboard-Terminbilder
|
# Generierte Dashboard-Terminbilder
|
||||||
web/intranet_repo/projects/dashboard/month/*
|
web/intranet_repo/projects/dashboard/month/*
|
||||||
|
web/intranet_repo/projects/dashboard/cache/
|
||||||
|
|
||||||
# Lokale Backup-Dateien
|
# Lokale Backup-Dateien
|
||||||
*.bak
|
*.bak
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ if (!in_array($generalPermission, [1, '1', true, 't', 'true'], true)) {
|
|||||||
exit();
|
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') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$action = trim((string) ($_POST['action'] ?? ''));
|
$action = trim((string) ($_POST['action'] ?? ''));
|
||||||
@@ -52,8 +54,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$flash = $_SESSION['contentpanel_flash'] ?? null;
|
$flash = $_SESSION['contentpanel_flash'] ?? null;
|
||||||
unset($_SESSION['contentpanel_flash']);
|
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);
|
$announcements = contentpanel_fetch_announcements($pdo, $today);
|
||||||
$newsItems = contentpanel_list_news();
|
$newsItems = contentpanel_list_news();
|
||||||
$pdo = null;
|
$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.php';
|
||||||
require_once __DIR__ . '/news_queue.php';
|
require_once __DIR__ . '/news_queue.php';
|
||||||
|
require_once __DIR__ . '/announcements.php';
|
||||||
|
|
||||||
function contentpanel_html($value): string
|
function contentpanel_html($value): string
|
||||||
{
|
{
|
||||||
@@ -294,6 +295,6 @@ function contentpanel_fetch_announcements(PDO $pdo, string $today): array
|
|||||||
return contentpanel_query($pdo,
|
return contentpanel_query($pdo,
|
||||||
'SELECT id, ansage_titel AS title, ansage_untertitel AS subtitle, ansage_datum_von AS date,
|
'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
|
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',
|
FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) >= ? ORDER BY ansage_datum_von',
|
||||||
[$today, $today])->fetchAll(PDO::FETCH_ASSOC);
|
[$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';
|
'use strict';
|
||||||
|
|
||||||
const CONTENT_REFRESH_MS = 30_000;
|
const config = JSON.parse(document.getElementById('dashboard-config').textContent);
|
||||||
const INDOOR_REFRESH_MS = 10_000;
|
const root = document.body;
|
||||||
const OUTDOOR_REFRESH_MS = 30_000;
|
|
||||||
const VERTICAL_SPEED_PX_PER_SECOND = 7;
|
|
||||||
const HORIZONTAL_SPEED_PX_PER_SECOND = 10;
|
|
||||||
|
|
||||||
const WEATHER_ICONS = {
|
function node(tag, className, text) {
|
||||||
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>`,
|
const element = document.createElement(tag);
|
||||||
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>`,
|
element.className = className;
|
||||||
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>`,
|
if (text !== undefined) element.textContent = text;
|
||||||
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>`,
|
return element;
|
||||||
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 setText(selector, value) {
|
function empty(stage, text) {
|
||||||
const element = document.querySelector(selector);
|
stage.replaceChildren(node('p', 'empty-state', text));
|
||||||
if (element) {
|
}
|
||||||
element.textContent = value ?? '';
|
|
||||||
|
// 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 now = new Date();
|
||||||
const clock = document.getElementById('uhrzeit');
|
const nextDay = berlinDay(now);
|
||||||
if (clock) {
|
clock.textContent = timeFormat.format(now);
|
||||||
clock.textContent = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
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 refreshContent = createRefresh('content', contentLoaded, contentUnavailable);
|
||||||
const container = document.querySelector('.news-scroll');
|
const refreshImages = createRefresh('images', data => {
|
||||||
if (!container || !Array.isArray(announcements)) {
|
imageData = data;
|
||||||
return;
|
return layoutImages();
|
||||||
}
|
}, imagesUnavailable);
|
||||||
|
const temperatureFormat = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 1 });
|
||||||
container.replaceChildren();
|
const refreshTemperatures = [
|
||||||
announcements.forEach((announcement) => {
|
['indoor', data => [data.indoor.value, data.indoor.unit]],
|
||||||
const box = document.createElement('div');
|
['outdoor', data => [data.outdoor.temperature, '°C']],
|
||||||
box.className = 'news-box';
|
].map(([id, read]) => {
|
||||||
|
const output = document.getElementById(id);
|
||||||
const date = document.createElement('div');
|
return createRefresh(id, data => {
|
||||||
date.className = 'news-date';
|
const [value, unit] = read(data);
|
||||||
date.append(document.createTextNode(announcement.date_label || ''));
|
if (!Number.isFinite(value)) throw new Error('Ungültige Temperatur');
|
||||||
if (announcement.end_label) {
|
const text = temperatureFormat.format(value) + ' ' + unit;
|
||||||
date.append(document.createElement('br'), document.createTextNode(announcement.end_label));
|
if (output.textContent !== text) output.textContent = text;
|
||||||
}
|
output.classList.remove('is-unavailable');
|
||||||
|
}, () => {
|
||||||
const body = document.createElement('div');
|
if (output.classList.contains('is-unavailable')) output.textContent = 'Nicht verfügbar';
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
function refreshAll() {
|
||||||
|
// Langsame Temperatursensoren dürfen den Aushangswechsel nicht verzögern.
|
||||||
|
refreshTemperatures.forEach(refresh => refresh());
|
||||||
|
return Promise.all([refreshContent(), refreshImages()]);
|
||||||
}
|
}
|
||||||
|
posters.onCycle = refreshAll;
|
||||||
function renderPosters(posters) {
|
refreshAll();
|
||||||
const container = document.querySelector('.aushang-flex');
|
tick();
|
||||||
if (!container || !Array.isArray(posters)) {
|
setInterval(tick, 1000);
|
||||||
return;
|
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);
|
||||||
}
|
}
|
||||||
|
const background = Math.min(...Array.from(document.querySelectorAll(
|
||||||
container.replaceChildren();
|
'.dashboard-panel, .dashboard-overview, .panel-heading, .announcement-entry'),
|
||||||
if (posters.length === 0) {
|
panel => luminance(getComputedStyle(panel).backgroundColor)));
|
||||||
const empty = document.createElement('p');
|
for (const [property, token] of [['--dashboard-text', '--nak-black'], ['--dashboard-meta', '--gray-700']]) {
|
||||||
empty.style.textAlign = 'left';
|
const contrast = (background + 0.05) / (luminance(style.getPropertyValue(token)) + 0.05);
|
||||||
empty.textContent = 'Keine Aushänge vorgeplant.';
|
if (contrast < config.CONTENT_MIN_CONTRAST_RATIO) root.style.setProperty(property, 'var(--nak-black)');
|
||||||
container.append(empty);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
posters.forEach((poster) => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
const image = document.createElement('img');
|
if (!document.hidden) {
|
||||||
image.src = poster.url;
|
tick();
|
||||||
image.alt = poster.alt || 'Aushang';
|
// Nach Rückkehr bekommt die sichtbare Seite erneut ihre volle Lesezeit.
|
||||||
image.style.height = '95%';
|
posters.schedule();
|
||||||
image.addEventListener('load', () => scheduleScrollingSetup(0), { once: true });
|
|
||||||
container.append(image);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMonthImages(images) {
|
|
||||||
if (!Array.isArray(images)) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
date_default_timezone_set('Europe/Berlin');
|
// Die Anzeigefläche muss auch ohne Datenbank oder externe APIs starten können.
|
||||||
require_once __DIR__ . '/util/dashboard_helpers.php';
|
$config = require __DIR__ . '/config.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');
|
|
||||||
?>
|
?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>NAK Reinickendorf</title>
|
<meta name="description" content="Aktuelle Informationen der Neuapostolischen Kirche Berlin-Reinickendorf.">
|
||||||
<link rel="icon" type="image/svg+xml" href="/intranet_repo/projects/_central/img/nak-logo.svg">
|
<title>Gemeindeinformationen · NAK Berlin-Reinickendorf</title>
|
||||||
<link rel="shortcut icon" href="/intranet_repo/projects/_central/img/favicon.ico">
|
<link rel="icon" type="image/svg+xml" href="../_central/img/nak-logo.svg">
|
||||||
<link rel="stylesheet" href="stylo.css?v=<?= $stylesheetVersion ?>">
|
<link rel="stylesheet" href="../_central/layouts/nak-design.css">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=SUSE:wght@400;700&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="stylo.css?v=<?= (int) filemtime(__DIR__ . '/stylo.css') ?>">
|
||||||
<script src="dashboard.js?v=<?= $scriptVersion ?>" defer></script>
|
<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>
|
</head>
|
||||||
<body>
|
<body class="dashboard">
|
||||||
<div class="content-board">
|
<main class="dashboard-main" aria-label="Gemeindeinformationen">
|
||||||
<div class="content-column-right">
|
<div class="dashboard-column announcements-column">
|
||||||
<div class="content-column-right-upper-box">
|
<section class="dashboard-overview" aria-label="Datum, Uhrzeit und Temperaturen">
|
||||||
<div class="content-column-right-upper-box-right">
|
<div class="dashboard-clock">
|
||||||
<div class="content-item-time">
|
<time id="clock" aria-label="Uhrzeit Europe/Berlin">–:–</time>
|
||||||
<div class="time"><div id="uhrzeit"></div></div>
|
<time id="date">Datum wird geladen</time>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<dl class="temperatures">
|
||||||
<div class="content-column-right-upper-box-left">
|
<div><dt>Innen</dt><dd id="indoor" class="is-unavailable">Wird geladen …</dd></div>
|
||||||
<div class="content-item-middle-top">
|
<div><dt>Außen</dt><dd id="outdoor" class="is-unavailable">Wird geladen …</dd></div>
|
||||||
<div class="header-sticky-note"><div>Bibelwort</div></div>
|
</dl>
|
||||||
<div class="bible"><?= dashboard_html($service['bibelwort']) ?></div>
|
</section>
|
||||||
<div class="verse"><?= dashboard_html($service['bibelvers']) ?></div>
|
<section class="dashboard-panel text-panel" id="announcements-panel" aria-labelledby="announcements-title">
|
||||||
</div>
|
<div class="panel-heading"><h1 id="announcements-title">Ansagen</h1></div>
|
||||||
|
<div class="text-stage"><p class="empty-state">Ansagen werden geladen …</p></div>
|
||||||
<div class="content-column-double-item">
|
</section>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-column-left">
|
<div class="dashboard-column bible-column">
|
||||||
<div class="content-item-video">
|
<section class="dashboard-panel text-panel bible-panel" id="bible-panel" aria-labelledby="bible-title">
|
||||||
<div class="video-container" aria-label="Leer"></div>
|
<div class="panel-heading"><h2 id="bible-title">Bibelwort für heute</h2></div>
|
||||||
</div>
|
<div class="text-stage"><p class="empty-state">Bibelwort wird geladen …</p></div>
|
||||||
|
</section>
|
||||||
<div class="content-item-month">
|
<section class="dashboard-panel text-panel" id="departures-panel" aria-labelledby="departures-title">
|
||||||
<div class="header-sticky-note"><div>Monatsplan</div></div>
|
<div class="panel-heading">
|
||||||
<div class="content-item-month-cell">
|
<h2 id="departures-title">Abfahrten: Eichborndamm/Antonienstr.</h2>
|
||||||
<?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>
|
</div>
|
||||||
</div>
|
<div class="text-stage"><p class="empty-state">Abfahrten werden geladen …</p></div>
|
||||||
|
</section>
|
||||||
</div>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ date_default_timezone_set('Europe/Berlin');
|
|||||||
require_once __DIR__ . '/util/dashboard_helpers.php';
|
require_once __DIR__ . '/util/dashboard_helpers.php';
|
||||||
|
|
||||||
$resource = (string) ($_GET['resource'] ?? 'content');
|
$resource = (string) ($_GET['resource'] ?? 'content');
|
||||||
$pdo = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch ($resource) {
|
switch ($resource) {
|
||||||
@@ -17,12 +16,22 @@ try {
|
|||||||
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
|
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
|
||||||
$payload = dashboard_fetch_content($pdo, dashboard_now());
|
$payload = dashboard_fetch_content($pdo, dashboard_now());
|
||||||
break;
|
break;
|
||||||
|
case 'images':
|
||||||
|
$payload = [
|
||||||
|
'posters' => dashboard_list_posters(dashboard_now()),
|
||||||
|
'month_images' => dashboard_month_images(),
|
||||||
|
];
|
||||||
|
break;
|
||||||
case 'indoor':
|
case 'indoor':
|
||||||
$payload = ['indoor' => dashboard_fetch_indoor_temperature()];
|
$payload = ['indoor' => dashboard_fetch_indoor_temperature()];
|
||||||
break;
|
break;
|
||||||
case 'outdoor':
|
case 'outdoor':
|
||||||
$payload = ['outdoor' => dashboard_fetch_outdoor_weather()];
|
$payload = ['outdoor' => dashboard_fetch_outdoor_weather()];
|
||||||
break;
|
break;
|
||||||
|
case 'departures_eich':
|
||||||
|
require_once __DIR__ . '/util/bvg.php';
|
||||||
|
$payload = dashboard_fetch_departures();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
$payload = ['error' => 'Unbekannte Dashboard-Ressource.'];
|
$payload = ['error' => 'Unbekannte Dashboard-Ressource.'];
|
||||||
@@ -33,6 +42,4 @@ try {
|
|||||||
http_response_code(502);
|
http_response_code(502);
|
||||||
error_log('Dashboard data endpoint (' . $resource . '): ' . $exception->getMessage());
|
error_log('Dashboard data endpoint (' . $resource . '): ' . $exception->getMessage());
|
||||||
echo json_encode(['error' => 'Dashboard-Daten konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE);
|
echo json_encode(['error' => 'Dashboard-Daten konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE);
|
||||||
} finally {
|
|
||||||
$pdo = null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,340 +1,124 @@
|
|||||||
/* Gesamtes Seiten-Layout */
|
/* Bildgrößen aus dem Original; Schrift und Farben aus den zentralen CD-Tokens. */
|
||||||
body {
|
body.dashboard {
|
||||||
margin: 0;
|
--dashboard-text: var(--nak-black);
|
||||||
height: 100vh;
|
--dashboard-meta: var(--gray-700);
|
||||||
display: flex;
|
--panel-surface: #fff;
|
||||||
justify-content: center;
|
--dashboard-unit: clamp(1px, 0.052083333vw, 2px);
|
||||||
align-items: center;
|
--panel-heading-height: calc(31 * var(--dashboard-unit));
|
||||||
font-family: 'SUSE', sans-serif;
|
/* Original: Board 97vh minus 7px Innenabstand, Monatsfeld 56%, Bildbereich 94%. */
|
||||||
font-size: 20px;
|
--original-board-height: calc(97vh - 7px);
|
||||||
background-image: url('cloudy.jpg');
|
--month-image-height: calc(var(--original-board-height) * 0.56 * 0.94);
|
||||||
background-size: cover;
|
--month-aspect: 1.414;
|
||||||
background-repeat: no-repeat;
|
--announcement-font: var(--bible-font);
|
||||||
background-position: center center;
|
--announcement-detail-font: calc(20 * var(--dashboard-unit));
|
||||||
overflow: hidden;
|
--announcement-meta-font: calc(15 * var(--dashboard-unit));
|
||||||
}
|
--announcement-leading: 1.35;
|
||||||
|
--bible-font: calc(23 * var(--dashboard-unit));
|
||||||
/* Content Board Layout */
|
display: block;
|
||||||
.content-board {
|
height: 100vh;
|
||||||
background-color: #a8a6a7a7;
|
height: 100dvh;
|
||||||
width: 99%;
|
min-height: 0;
|
||||||
height: 97%;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
color: var(--dashboard-text);
|
||||||
display: flex;
|
background: var(--nak-blue-light) url('cloudy.jpg') center / cover no-repeat;
|
||||||
justify-content: space-around;
|
overflow: hidden;
|
||||||
align-items: center;
|
}
|
||||||
border-radius: 0;
|
.dashboard [hidden] { display: none !important; }
|
||||||
padding: 0 6px 7px 3px;
|
.dashboard-main {
|
||||||
}
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.05fr) calc(var(--month-image-height) * var(--month-aspect));
|
||||||
.content-column-left {
|
grid-template-rows: calc(var(--month-image-height) + var(--panel-heading-height)) minmax(0, 1fr);
|
||||||
width: 40%;
|
grid-template-areas: "announcements bible month" "posters posters posters";
|
||||||
height: 100%;
|
gap: var(--space-sm);
|
||||||
box-sizing: border-box;
|
height: 100%;
|
||||||
}
|
min-height: 0;
|
||||||
|
padding: var(--space-sm);
|
||||||
.content-column-right {
|
}
|
||||||
width: 58%;
|
#month-panel { grid-area: month; --panel-accent: var(--nak-purple); }
|
||||||
height: 100%;
|
#poster-panel { grid-area: posters; --panel-accent: var(--nak-orange); --poster-frame-width: 2px; }
|
||||||
box-sizing: border-box;
|
#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); }
|
||||||
.temperature {
|
.bible-column { grid-area: bible; grid-template-rows: minmax(0, 1fr) auto; }
|
||||||
display: flex;
|
.dashboard-panel { min-width: 0; min-height: 0; background: var(--panel-surface); }
|
||||||
justify-content: space-around;
|
.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); }
|
||||||
align-items: center;
|
.dashboard-clock { display: grid; flex: 0 0 auto; justify-items: start; gap: calc(4 * var(--dashboard-unit)); }
|
||||||
padding: 5px 0 0 0;
|
#clock, #date { width: max-content; }
|
||||||
height: 70%;
|
#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; }
|
||||||
.verse {
|
.temperatures > div { min-width: 0; }
|
||||||
text-align: justify;
|
.temperatures dt { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); }
|
||||||
color: rgb(67, 67, 67);
|
.temperatures dd { margin: 0; font-size: var(--announcement-detail-font); line-height: var(--announcement-leading); font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||||
padding: 5px 15px 5px 15px;
|
.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); }
|
||||||
.date {
|
.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; }
|
||||||
color: rgb(67, 67, 67);
|
.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; }
|
||||||
font-weight: bold;
|
.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); }
|
||||||
.content-column-right-upper-box-left {
|
#month-panel .image-stage,
|
||||||
width: 60%;
|
#poster-panel .image-stage { padding: calc(4 * var(--dashboard-unit)); }
|
||||||
height: 100%;
|
.image-sheet { display: flex; justify-content: center; align-items: center; gap: var(--space-sm); width: 100%; height: 100%; min-height: 0; }
|
||||||
box-sizing: border-box;
|
#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); }
|
||||||
.content-column-right-upper-box-right {
|
.image-cell img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||||
width: 39%;
|
.image-cell.is-cropped { position: relative; overflow: hidden; }
|
||||||
height: 100%;
|
.image-cell.is-cropped img { position: absolute; max-width: none; }
|
||||||
box-sizing: border-box;
|
.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; }
|
||||||
.content-column-right-upper-box {
|
#announcements-panel .text-stage > .empty-state { padding: 0 calc(10 * var(--dashboard-unit)); }
|
||||||
width: 100%;
|
.text-page { color: var(--dashboard-text); }
|
||||||
height: 51%;
|
.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); }
|
||||||
box-sizing: border-box;
|
.text-page p:last-child { margin-bottom: 0; }
|
||||||
display: flex;
|
.text-page .announcement-title, .text-page .bible-reference { font-weight: 500; }
|
||||||
justify-content: space-between;
|
.text-page .announcement-subtitle { font-size: var(--announcement-detail-font); color: var(--dashboard-meta); }
|
||||||
align-items: center;
|
.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. */
|
||||||
.content-item-time {
|
.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); }
|
||||||
border-radius: 0;
|
.announcement-entry:nth-child(even) { background: var(--gray-50); }
|
||||||
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
|
.announcement-content { min-width: 0; }
|
||||||
background-color: #ffffffab;
|
.announcement-entry p { margin: 0; }
|
||||||
width: 100%;
|
.announcement-content .announcement-meta { font-style: italic; }
|
||||||
height: 30%;
|
.announcement-when { min-width: 0; font-weight: 300; color: var(--dashboard-meta); font-variant-numeric: tabular-nums; }
|
||||||
margin: 15px 0 15px 0;
|
.announcement-when p { margin-bottom: 0; font-size: var(--announcement-detail-font); }
|
||||||
box-sizing: border-box;
|
.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; }
|
||||||
.content-item-news {
|
#departures-panel { --panel-accent: var(--nak-darkblue); height: calc(161 * var(--dashboard-unit)); }
|
||||||
border-radius: 0;
|
#departures-panel .text-stage { margin: 0; }
|
||||||
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
|
#departures-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(18 * var(--dashboard-unit)); }
|
||||||
background-color: #ffffffab;
|
.departures-list { max-width: none; margin: 0; padding: 0; list-style: none; }
|
||||||
width: 100%;
|
.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; }
|
||||||
height: 63%;
|
.departure-row:nth-child(even) { background: var(--gray-50); }
|
||||||
margin: 15px 0 15px 0;
|
.departure-row.is-ontime, .departure-row.is-scheduled { --departure-color: var(--nak-darkgreen); }
|
||||||
box-sizing: border-box;
|
.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; }
|
||||||
.content-item-video {
|
.departure-direction { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
border-radius: 0;
|
.departure-remaining, .departure-status { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||||
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
|
#departures-panel .empty-state { padding: calc(8 * var(--dashboard-unit)); font-size: var(--announcement-meta-font); }
|
||||||
background-color: #cbc5bc;
|
.text-stage.is-overflowing::after {
|
||||||
width: 100%;
|
content: "";
|
||||||
height: 40%;
|
position: absolute;
|
||||||
margin: 15px 0 15px 0;
|
inset: auto 0 0;
|
||||||
box-sizing: border-box;
|
height: calc(24 * var(--dashboard-unit));
|
||||||
}
|
background: linear-gradient(to bottom, transparent, var(--panel-surface));
|
||||||
|
pointer-events: none;
|
||||||
.content-item-middle-top {
|
}
|
||||||
position: relative;
|
.empty-state { margin: 0; font-size: var(--announcement-font); line-height: var(--announcement-leading); color: var(--dashboard-meta); }
|
||||||
border-radius: 0;
|
.image-stage > .empty-state { padding: var(--space-md); }
|
||||||
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
|
.dashboard noscript { position: fixed; bottom: 0; padding: var(--space-lg); background: #fff; color: var(--nak-red); }
|
||||||
background-color: #cbc5bc;
|
@media (max-width: 999px), (max-height: 599px) {
|
||||||
width: 100%;
|
body.dashboard { height: auto; min-height: 100vh; overflow: visible; }
|
||||||
height: 60%;
|
.dashboard-main {
|
||||||
margin: 15px 0 15px 0;
|
height: auto;
|
||||||
box-sizing: border-box;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
overflow: hidden;
|
grid-template-areas: "announcements" "bible" "month" "posters";
|
||||||
}
|
grid-template-rows: 520px 520px 65vh 55vh;
|
||||||
|
}
|
||||||
/* 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)); }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
<?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';
|
require_once __DIR__ . '/../../../general_files/ini/runtime_env.php';
|
||||||
|
|
||||||
const DASHBOARD_TIMEZONE = 'Europe/Berlin';
|
const DASHBOARD_TIMEZONE = 'Europe/Berlin';
|
||||||
const DASHBOARD_HA_ENTITY_ID = 'sensor.temp_temperatur';
|
const DASHBOARD_HA_ENTITY_ID = 'sensor.temp_temperatur';
|
||||||
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571291';
|
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571524';
|
||||||
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.310422';
|
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.312494';
|
||||||
const DASHBOARD_NEWS_URL = '/intranet_repo/projects/contentpanel/insertion/news/png/';
|
const DASHBOARD_NEWS_URL = '/intranet_repo/projects/contentpanel/insertion/news/png/';
|
||||||
|
|
||||||
function dashboard_now(): DateTimeImmutable
|
function dashboard_now(): DateTimeImmutable
|
||||||
@@ -14,141 +17,151 @@ function dashboard_now(): DateTimeImmutable
|
|||||||
return new DateTimeImmutable('now', new DateTimeZone(DASHBOARD_TIMEZONE));
|
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
|
function dashboard_fetch_content(PDO $pdo, DateTimeImmutable $now): array
|
||||||
{
|
{
|
||||||
|
$now = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE));
|
||||||
$today = $now->format('Y-m-d');
|
$today = $now->format('Y-m-d');
|
||||||
|
$payload = ['date' => $today, 'service' => null, 'announcements' => [], 'errors' => []];
|
||||||
|
|
||||||
$serviceStatement = $pdo->prepare(
|
// Unabhängige Abfragen: ein fehlerhafter Bereich blockiert den anderen nicht.
|
||||||
'SELECT bibelwort, bibelvers, eingang_nummer, eingang_titel
|
try {
|
||||||
FROM gottesdienst
|
$statement = $pdo->prepare(
|
||||||
WHERE bibeldatum = ?'
|
'SELECT bibelwort, bibelvers FROM gottesdienst
|
||||||
);
|
WHERE bibeldatum = ? ORDER BY id ASC LIMIT 1'
|
||||||
$serviceStatement->execute([$today]);
|
);
|
||||||
$service = $serviceStatement->fetch(PDO::FETCH_ASSOC) ?: [];
|
$statement->execute([$today]);
|
||||||
|
$service = $statement->fetch(PDO::FETCH_ASSOC);
|
||||||
$announcementStatement = $pdo->prepare(
|
if ($service) {
|
||||||
'SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort, ansage_titel, ansage_untertitel
|
// Leitgedanken-Import: bibelwort = Referenz, bibelvers = eigentlicher Text.
|
||||||
FROM ansagen
|
$payload['service'] = [
|
||||||
WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ?
|
'reference' => trim((string) ($service['bibelwort'] ?? '')),
|
||||||
ORDER BY ansage_datum_von ASC'
|
'text' => trim((string) ($service['bibelvers'] ?? '')),
|
||||||
);
|
];
|
||||||
$announcementStatement->execute([$today, $today]);
|
}
|
||||||
$announcements = $announcementStatement->fetchAll(PDO::FETCH_ASSOC);
|
} catch (Throwable $exception) {
|
||||||
|
error_log('Dashboard Bibelwort: ' . $exception->getMessage());
|
||||||
foreach ($announcements as &$announcement) {
|
$payload['errors'][] = 'service';
|
||||||
$announcement = dashboard_prepare_announcement($announcement, $now);
|
|
||||||
}
|
}
|
||||||
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 [
|
return $payload;
|
||||||
'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(),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dashboard_date_label(DateTimeImmutable $date): string
|
function dashboard_date(string $raw): ?DateTimeImmutable
|
||||||
{
|
{
|
||||||
static $months = [
|
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw, new DateTimeZone(DASHBOARD_TIMEZONE));
|
||||||
1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
return $date && $date->format('Y-m-d') === $raw ? $date : null;
|
||||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
|
||||||
];
|
|
||||||
|
|
||||||
return sprintf('%02d. %s', (int) $date->format('d'), $months[(int) $date->format('n')]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dashboard_prepare_announcement(array $announcement, DateTimeImmutable $now): array
|
function dashboard_prepare_announcement(array $announcement, DateTimeImmutable $now): array
|
||||||
{
|
{
|
||||||
$startRaw = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10);
|
$now = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE));
|
||||||
$endRaw = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10);
|
$start = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10);
|
||||||
$timeRaw = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5);
|
$end = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10);
|
||||||
|
$today = $now->format('Y-m-d');
|
||||||
$startDate = DateTimeImmutable::createFromFormat('!Y-m-d', $startRaw, $now->getTimezone());
|
$startDate = dashboard_date($start);
|
||||||
$tomorrow = $now->setTime(0, 0)->modify('+1 day');
|
$endDate = dashboard_date($end);
|
||||||
|
if ($start === $today) {
|
||||||
if ($startDate && $startDate->format('Y-m-d') === $now->format('Y-m-d')) {
|
$label = 'Heute';
|
||||||
$dateLabel = 'Heute';
|
} elseif ($start === $now->modify('+1 day')->format('Y-m-d')) {
|
||||||
} elseif ($startDate && $startDate->format('Y-m-d') === $tomorrow->format('Y-m-d')) {
|
$label = 'Morgen';
|
||||||
$dateLabel = 'Morgen';
|
|
||||||
} elseif ($startDate) {
|
|
||||||
static $weekdays = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
|
||||||
$dateLabel = $weekdays[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.');
|
|
||||||
} else {
|
} else {
|
||||||
$dateLabel = $startRaw;
|
$days = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||||
|
$label = $startDate ? $days[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.') : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$endLabel = '';
|
$endLabel = $endDate && $end !== $start ? 'bis ' . $endDate->format('d.m.') : '';
|
||||||
if ($endRaw !== '') {
|
$time = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5);
|
||||||
$endDate = DateTimeImmutable::createFromFormat('!Y-m-d', $endRaw, $now->getTimezone());
|
|
||||||
$endLabel = $endDate ? 'bis ' . $endDate->format('d.m.') : 'bis ' . $endRaw;
|
|
||||||
}
|
|
||||||
|
|
||||||
$meta = [];
|
|
||||||
if ($timeRaw !== '') {
|
|
||||||
$meta[] = $timeRaw . ' Uhr';
|
|
||||||
}
|
|
||||||
$place = trim((string) ($announcement['ansage_ort'] ?? ''));
|
$place = trim((string) ($announcement['ansage_ort'] ?? ''));
|
||||||
if ($place !== '') {
|
|
||||||
$meta[] = $place;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'date_label' => $dateLabel,
|
'date_label' => $label,
|
||||||
'end_label' => $endLabel,
|
'end_label' => $endLabel,
|
||||||
'title' => (string) ($announcement['ansage_titel'] ?? ''),
|
'time' => $time,
|
||||||
'subtitle' => (string) ($announcement['ansage_untertitel'] ?? ''),
|
'place' => $place,
|
||||||
'meta' => implode(', ', $meta),
|
'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') ?: [];
|
if (!is_file($path)) {
|
||||||
natsort($files);
|
return null;
|
||||||
|
|
||||||
$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',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
$size = @getimagesize($path);
|
||||||
return array_values($posters);
|
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 = [];
|
$images = [];
|
||||||
foreach ([1, 2] as $page) {
|
foreach ([1, 2] as $page) {
|
||||||
$filename = 'terminuebersicht-' . $page . '.png';
|
$filename = 'terminuebersicht-' . $page . '.png';
|
||||||
$path = __DIR__ . '/../month/' . $filename;
|
$image = dashboard_image($directory . '/' . $filename, 'month/' . $filename);
|
||||||
$version = is_file($path) ? (int) filemtime($path) : 0;
|
if ($image) {
|
||||||
$images[] = [
|
$images[] = $image + ['alt' => 'Monatsplan · Seite ' . $page];
|
||||||
'page' => $page,
|
}
|
||||||
'url' => 'month/' . $filename . '?v=' . $version,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $images;
|
return $images;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,17 +172,12 @@ function dashboard_fetch_indoor_temperature(): array
|
|||||||
$url = $baseUrl . '/api/states/' . rawurlencode(DASHBOARD_HA_ENTITY_ID);
|
$url = $baseUrl . '/api/states/' . rawurlencode(DASHBOARD_HA_ENTITY_ID);
|
||||||
$data = dashboard_http_json($url, ['Authorization: Bearer ' . $token]);
|
$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.');
|
throw new RuntimeException('Unerwartete Home-Assistant-Antwort.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$value = $data['state'];
|
|
||||||
if (is_numeric($value)) {
|
|
||||||
$value = round((float) $value, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'value' => $value,
|
'value' => (int) ceil((float) $data['state'] - 0.4),
|
||||||
'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'),
|
'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);
|
$data = dashboard_http_json('https://api.openweathermap.org/data/2.5/weather?' . $query);
|
||||||
$temperature = $data['main']['temp'] ?? null;
|
$temperature = $data['main']['temp'] ?? null;
|
||||||
$icon = (string) ($data['weather'][0]['icon'] ?? '');
|
|
||||||
|
|
||||||
if (!is_numeric($temperature)) {
|
if (!is_numeric($temperature)) {
|
||||||
throw new RuntimeException('Unerwartete OpenWeather-Antwort.');
|
throw new RuntimeException('Unerwartete OpenWeather-Antwort.');
|
||||||
@@ -194,27 +201,9 @@ function dashboard_fetch_outdoor_weather(): array
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'temperature' => (int) round((float) $temperature),
|
'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
|
function dashboard_http_json(string $url, array $headers = []): array
|
||||||
{
|
{
|
||||||
$handle = curl_init($url);
|
$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.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)
|
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)
|
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.assertEqual(denied.status, 303)
|
||||||
self.assertIn('/intranet/index.php', denied.headers['location'])
|
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.request.post(self.url, form={'action': 'delete_news', 'file_base': '2030-12-31-Mehrseitig', 'csrf_token': 'wrong'}, max_redirects=0)
|
||||||
self.page.reload()
|
self.page.reload()
|
||||||
self.assertIn('Ungültige Anfrage', self.page.locator('.sb-alert-error').inner_text())
|
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):
|
def test_forms_preserve_fields_validation_and_bound_sql_parameters(self):
|
||||||
for tab, action, fields in (
|
for tab, action, fields in (
|
||||||
('gottesdienst', 'create_service', {'bibeldatum': '2030-12-31', 'bibelwort': 'Joh 3,16', 'bibelvers': 'Text & "Zitat"'}),
|
('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 __construct(private string $sql) {}
|
||||||
public function execute(?array $params = null): bool
|
public function execute(?array $params = null): bool
|
||||||
{
|
{
|
||||||
if (!str_starts_with($this->sql, 'SELECT')) {
|
if (!str_starts_with($this->sql, 'SELECT')) {
|
||||||
file_put_contents($_SERVER['DOCUMENT_ROOT'] . '/writes.jsonl', json_encode([$this->sql, $params]) . "\n", FILE_APPEND);
|
$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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,12 +61,11 @@ def has_style(value, declaration):
|
|||||||
|
|
||||||
|
|
||||||
def event_rows(cell):
|
def event_rows(cell):
|
||||||
blocks = cell.find_all("div", class_="calEventBox")
|
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
|
||||||
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
|
|
||||||
|
for title in titles:
|
||||||
for index, title in enumerate(titles):
|
time_text = ""
|
||||||
time_text, place = "", "--"
|
block = title.find_parent("div", class_="calEventBox")
|
||||||
block = blocks[index] if index < len(blocks) else None
|
|
||||||
details = block and block.find(
|
details = block and block.find(
|
||||||
style=lambda value: has_style(value, "font-weight:normal")
|
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()]
|
parts = [part.strip() for part in details.stripped_strings if part.strip()]
|
||||||
if parts and ":" in parts[0]:
|
if parts and ":" in parts[0]:
|
||||||
time_text = parts.pop(0)
|
time_text = parts.pop(0)
|
||||||
if parts:
|
# Nur das Ortsfeld auswerten, nicht Trennzeichen, Datumsbereiche oder Notizen.
|
||||||
place = parts[0].lstrip(", ") or "--"
|
location = block and block.find(attrs={"name": "show_place"})
|
||||||
yield time_text, title.get_text(strip=True), 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):
|
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):
|
def add_month_section(pdf, cells, month, now, today=None, planning_note=False):
|
||||||
name = MONTHS[month.month - 1]
|
name = MONTHS[month.month - 1]
|
||||||
pdf.set_font("helvetica", "B", 16)
|
pdf.set_font("helvetica", "B", 16)
|
||||||
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
|
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
|
||||||
pdf.set_font("helvetica", "", 10)
|
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 = (
|
subtitle = (
|
||||||
f"Die Termine für {name} befinden sich noch in Planung und können sich ändern."
|
f"Die Termine für {name} befinden sich noch in Planung und können sich ändern."
|
||||||
if planning_note else "der Gemeinde Berlin-Reinickendorf"
|
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.set_auto_page_break(auto=True, margin=15)
|
||||||
pdf.add_page()
|
pdf.add_page()
|
||||||
|
|
||||||
logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
|
for index, month in enumerate(months):
|
||||||
if not os.path.isfile(logo):
|
if index and pdf.page_no() == 1:
|
||||||
raise RuntimeError(f"Logo fehlt: {logo}")
|
pdf.add_page()
|
||||||
pdf.image(logo, 188, 9.5, 11)
|
elif index:
|
||||||
|
pdf.cell(0, 6, " ", 0, 1)
|
||||||
for index, month in enumerate(months):
|
|
||||||
if index:
|
|
||||||
pdf.cell(0, 6, " ", 0, 1)
|
|
||||||
add_month_section(
|
add_month_section(
|
||||||
pdf, cells, month, now,
|
pdf, cells, month, now,
|
||||||
today=today if index == 0 else None,
|
today=today if index == 0 else None,
|
||||||
|
|||||||
Reference in New Issue
Block a user