Author SHA1 Message Date
samuelbias 87fc43db87 dashboard - first draft of complete redesign 2026-09-03 08:19:00 +02:00
11 changed files with 759 additions and 816 deletions
@@ -9,7 +9,9 @@ if (!in_array($generalPermission, [1, '1', true, 't', 'true'], true)) {
exit();
}
contentpanel_cleanup_expired_news();
$today = date('Y-m-d');
contentpanel_cleanup_expired_news();
contentpanel_cleanup_expired_announcements($pdo, $today);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = trim((string) ($_POST['action'] ?? ''));
@@ -52,8 +54,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$flash = $_SESSION['contentpanel_flash'] ?? null;
unset($_SESSION['contentpanel_flash']);
$today = date('Y-m-d');
$services = contentpanel_fetch_services($pdo, $today);
$services = contentpanel_fetch_services($pdo, $today);
$announcements = contentpanel_fetch_announcements($pdo, $today);
$newsItems = contentpanel_list_news();
$pdo = null;
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
function contentpanel_cleanup_expired_announcements(PDO $pdo, string $today): void
{
// Das Enddatum ist einschließlich gültig; ohne Enddatum zählt das Startdatum.
// Datensätze ohne beide Datumswerte werden nicht als abgelaufen gelöscht.
$statement = $pdo->prepare(
'DELETE FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?'
);
$statement->execute([$today]);
}
@@ -2,6 +2,7 @@
require_once __DIR__ . '/news.php';
require_once __DIR__ . '/news_queue.php';
require_once __DIR__ . '/announcements.php';
function contentpanel_html($value): string
{
@@ -294,6 +295,6 @@ function contentpanel_fetch_announcements(PDO $pdo, string $today): array
return contentpanel_query($pdo,
'SELECT id, ansage_titel AS title, ansage_untertitel AS subtitle, ansage_datum_von AS date,
ansage_datum_bis AS end_date, ansage_uhrzeit AS time, ansage_ort AS location
FROM ansagen WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ? ORDER BY ansage_datum_von',
[$today, $today])->fetchAll(PDO::FETCH_ASSOC);
FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) >= ? ORDER BY ansage_datum_von',
[$today])->fetchAll(PDO::FETCH_ASSOC);
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
// Zentrale Anzeige- und Lesbarkeitswerte, ohne Build-Schritt.
return [
// Original: (97vh - 7px) × 47,5% Feld × 90,5% Leiste × 95% Bildhöhe.
'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,
'ANNOUNCEMENT_MIN_FONT_SIZE_PX' => 20,
'ANNOUNCEMENT_META_MIN_FONT_SIZE_PX' => 15,
'BIBLE_FONT_SIZE_PX' => 23,
'ANNOUNCEMENT_LINE_HEIGHT' => 1.35,
'ANNOUNCEMENT_MAX_LINE_LENGTH_CH' => 85,
'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,
];
+417 -218
View File
@@ -1,236 +1,435 @@
(() => {
'use strict';
const CONTENT_REFRESH_MS = 30_000;
const INDOOR_REFRESH_MS = 10_000;
const OUTDOOR_REFRESH_MS = 30_000;
const VERTICAL_SPEED_PX_PER_SECOND = 7;
const HORIZONTAL_SPEED_PX_PER_SECOND = 10;
const config = JSON.parse(document.getElementById('dashboard-config').textContent);
const root = document.body;
for (const [property, value] of Object.entries({
'--announcement-detail-font': 'calc(' + config.ANNOUNCEMENT_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))',
'--announcement-meta-font': 'calc(' + config.ANNOUNCEMENT_META_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))',
'--announcement-leading': config.ANNOUNCEMENT_LINE_HEIGHT,
'--bible-font': 'calc(' + config.BIBLE_FONT_SIZE_PX + ' * var(--dashboard-unit))',
'--announcement-measure': config.ANNOUNCEMENT_MAX_LINE_LENGTH_CH + 'ch',
})) root.style.setProperty(property, value);
const WEATHER_ICONS = {
cloudy: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><defs><filter id="weather-blur" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur in="SourceAlpha" stdDeviation="2.5"/><feOffset dx="0" dy="2" result="off"/><feComponentTransfer><feFuncA type="linear" slope="0.15"/></feComponentTransfer><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><g filter="url(#weather-blur)"><path d="M22 46h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 20 29a9 9 0 0 0 2 17z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M18 46h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 17 31a8 8 0 0 0 1 15z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></svg>`,
sun: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><circle cx="32" cy="32" r="10" fill="#FFD34D" stroke="#fff"/><path d="M32 6v8M32 50v8M6 32h8M50 32h8M13.5 13.5l5.7 5.7M44.8 44.8l5.7 5.7M50.5 13.5l-5.7 5.7M19.2 44.8l-5.7 5.7"/></g></svg>`,
moon: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><path d="M41 8a22 22 0 1 0 15 35A18 18 0 0 1 41 8z" fill="#DDE7FF" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></svg>`,
cloudy_day: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><g transform="translate(4,0)"><circle cx="22" cy="22" r="9" fill="#FFD34D" stroke="#fff" stroke-width="2"/><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M22 6v6M22 32v6M6 22h6M32 22h6M10.5 10.5l4.2 4.2M29.3 29.3l4.2 4.2M33.5 10.5l-4.2 4.2M14.7 29.3l-4.2 4.2"/></g></g><g transform="translate(8,10)"><path d="M18 36h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 16 19a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M22 36h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 21 21a8 8 0 0 0 1 15z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></g></svg>`,
cloudy_night: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M38 10a16 16 0 1 0 11 26A13 13 0 0 1 38 10z" fill="#DDE7FF" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g transform="translate(10,18)"><path d="M14 28h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 12 11a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M18 28h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 17 13a8 8 0 0 0 1 15z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></g></svg>`,
rain_shower: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 36h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 19a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M24 42l-3 6"/><path d="M34 42l-3 6"/><path d="M44 42l-3 6"/></g></g></svg>`,
rain: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 34h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 17a9 9 0 0 0 2 17z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M22 40l-3 8"/><path d="M32 40l-3 8"/><path d="M42 40l-3 8"/><path d="M52 40l-3 8"/></g></g></svg>`,
thunder: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M18 34h28a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 16 17a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M32 36l-6 12h7l-3 10 12-16h-7l3-6z" fill="#FFD34D" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></g></svg>`,
snowy: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 34h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 17a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g fill="#fff"><circle cx="24" cy="44" r="2"/><circle cx="34" cy="44" r="2"/><circle cx="44" cy="44" r="2"/><circle cx="29" cy="52" r="2"/><circle cx="39" cy="52" r="2"/></g></g></svg>`,
mist: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity="0.95"><path d="M14 24h36"/><path d="M10 32h44"/><path d="M14 40h36"/><path d="M18 48h28"/></g></svg>`,
};
function node(tag, className, text) {
const element = document.createElement(tag);
element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function setText(selector, value) {
const element = document.querySelector(selector);
if (element) {
element.textContent = value ?? '';
function empty(stage, text) {
stage.replaceChildren(node('p', 'empty-state', text));
}
// Timeout und HTTP-Prüfung gelten für Inhalte und Bilder gleichermaßen.
async function request(url, read, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.REQUEST_TIMEOUT_MS);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
if (!response.ok) throw new Error('HTTP ' + response.status);
return await read(response);
} finally {
clearTimeout(timeout);
}
}
function updateClock() {
const aspectRatio = pages => pages.reduce((sum, item) => sum + item.width / item.height, 0);
const layoutKey = layout => [layout.width, layout.height, layout.groups.map(group => group.length)].join('/');
const dayFormatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit',
});
function berlinDay(date = new Date()) {
const parts = Object.fromEntries(dayFormatter.formatToParts(date).map(part => [part.type, part.value]));
return parts.year + '-' + parts.month + '-' + parts.day;
}
let currentDay = berlinDay();
function groupDocuments(pages) {
const entries = new Map();
for (const item of pages) {
const key = item.document ?? item.url;
if (!entries.has(key)) entries.set(key, []);
entries.get(key).push(item);
}
return [...entries.values()].map(pages => pages.sort((a, b) => a.page - b.page));
}
class ImageGallery {
constructor(id, message, rotate = false) {
Object.assign(this, {
message, rotate, items: [], current: [], images: new Map(),
index: 0, generation: 0, signature: '', layoutKey: '', failed: false, pending: false,
});
this.panel = document.getElementById(id);
this.stage = this.panel.querySelector('.image-stage');
this.progress = this.panel.querySelector('.poster-progress');
new ResizeObserver(() => this.resize()).observe(this.stage);
}
layout() {
// Keine Ganzzahlrundung durch clientHeight: Original-Bildhöhe exakt erhalten.
const bounds = this.stage.getBoundingClientRect();
const style = getComputedStyle(this.stage);
const width = bounds.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
const height = bounds.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
const gap = this.rotate ? parseFloat(style.columnGap) : 0;
const frame = this.rotate ? parseFloat(style.getPropertyValue('--poster-frame-width')) : 0;
if (!this.rotate) return { groups: [this.items], width, height, gap, frame };
const minimum = innerHeight * config.POSTER_MIN_RENDER_HEIGHT_VH / 100
- config.POSTER_MIN_RENDER_HEIGHT_OFFSET_PX;
const occupied = pages => minimum * aspectRatio(pages) + groupDocuments(pages).length * (2 * frame + gap);
const fits = pages => pages.length <= config.POSTER_MAX_VISIBLE_PAGES
&& occupied(pages) <= width + gap;
// Nur Aushänge aufteilen, die selbst nicht in eine Ansicht passen.
const chunks = [];
for (const pages of groupDocuments(this.items)) {
let chunk = [];
for (const item of pages) {
if (chunk.length && !fits([...chunk, item])) {
chunks.push(chunk);
chunk = [];
}
chunk.push(item);
}
if (chunk.length) chunks.push(chunk);
}
// Große Dokumente zuerst, kleinere in die Lücken: Datum ist nachrangig.
chunks.sort((a, b) => occupied(b) - occupied(a));
const groups = [];
for (const chunk of chunks) {
const group = groups.find(group => fits([...group, ...chunk]));
if (group) group.push(...chunk);
else groups.push([...chunk]);
}
// Zuerst vollständige Dokumente aus der vorigen Ansicht übernehmen,
// verbleibende Plätze nötigenfalls mit einzelnen Seiten auffüllen.
const primaryGroups = groups.map(group => [...group]);
for (let index = 0; groups.length > 1 && index < groups.length; index++) {
const current = groups[index];
const previous = primaryGroups[(index + groups.length - 1) % groups.length];
const available = previous.filter(item =>
!current.some(entry => entry.url === item.url));
for (const pages of groupDocuments(available).reverse()) {
if (fits([...current, ...pages])) current.unshift(...pages);
}
for (const item of [...available].reverse()) {
if (!current.some(entry => entry.url === item.url)
&& fits([...current, item])) current.unshift(item);
}
groups[index] = groupDocuments(current).flat();
}
return { groups, width, height, gap, frame };
}
loadImage(item) {
if (!this.images.has(item.url)) {
let localUrl;
this.images.set(item.url, request(item.url, async response => {
localUrl = URL.createObjectURL(await response.blob());
const image = new Image();
image.alt = item.alt;
image.dataset.source = item.url;
image.src = localUrl;
await image.decode();
return image;
}).catch(() => {
if (localUrl) URL.revokeObjectURL(localUrl);
this.images.delete(item.url);
return null;
}));
}
return this.images.get(item.url);
}
pruneImages() {
for (const [url, pending] of this.images) {
if (this.items.some(item => item.url === url) || this.current.some(item => item.url === url)) continue;
this.images.delete(url);
pending.then(image => { if (image) URL.revokeObjectURL(image.src); });
}
}
update(items, message = this.message) {
const signature = JSON.stringify([items, message]);
if (signature === this.signature && !this.failed) return;
this.signature = signature;
this.message = message;
this.items = items;
// Nur neue Bildversionen laden; unveränderte Blob-URLs wiederverwenden.
for (const item of items) this.loadImage(item);
if (!items.length) {
this.generation++;
clearTimeout(this.timer);
this.current = [];
this.pending = false;
empty(this.stage, message);
this.pruneImages();
this.schedule();
return;
}
// Nur abgelaufene Seiten sofort entfernen; sonst bleiben vorhandene
// Bilder bis zum vollständig geladenen Ersatz sichtbar.
if (this.rotate && this.current.some(item => item.expires < currentDay)) {
this.current = [];
empty(this.stage, this.message);
}
if (this.rotate && this.current.length) {
this.pending = true;
return;
}
return this.show(0);
}
resize() {
if (this.items.length && layoutKey(this.layout()) !== this.layoutKey) this.show(this.index);
}
schedule() {
clearTimeout(this.timer);
if (!this.rotate) return;
const duration = this.items.length ? config.POSTER_MIN_DWELL_TIME_S * 1000 : config.EMPTY_REFRESH_MS;
this.progressAnimation?.cancel();
this.progress.toggleAttribute('hidden', !this.items.length);
if (this.items.length) {
this.progressAnimation = this.progress.querySelector('.poster-progress-value').animate(
[{ strokeDashoffset: 100 }, { strokeDashoffset: 0 }],
{ duration, easing: 'linear', fill: 'forwards' });
}
this.timer = setTimeout(async () => {
if (this.items.length && !this.pending && this.index + 1 < this.layout().groups.length) {
return this.show(this.index + 1);
}
// Texte und Bildbestand nach einem vollständigen Durchlauf prüfen.
await this.onCycle?.();
if (this.items.length && (this.pending || this.failed || this.index !== 0)) this.show(0);
else this.schedule();
}, duration);
}
async show(index) {
if (!this.items.length) return;
this.pending = false;
const layout = this.layout();
this.layoutKey = layoutKey(layout);
this.index = index % layout.groups.length;
const batch = layout.groups[this.index];
const documents = this.rotate ? groupDocuments(batch) : [batch];
const renderHeight = Math.min(layout.height - 2 * layout.frame,
(layout.width - (documents.length - 1) * layout.gap - documents.length * 2 * layout.frame) / aspectRatio(batch));
const generation = ++this.generation;
clearTimeout(this.timer);
const sheet = node('div', 'image-sheet');
let failures = 0;
// Gemeinsam gerahmte Seiten laden unabhängig; erst die fertige Ansicht einblenden.
await Promise.all(documents.flatMap(pages => {
const wrapper = this.rotate ? node('div', 'poster-document') : sheet;
if (this.rotate) sheet.append(wrapper);
return pages.map(async item => {
const cell = node('div', 'image-cell');
cell.style.height = renderHeight + 'px';
cell.style.width = (renderHeight * item.width / item.height) + 'px';
wrapper.append(cell);
const copy = (await this.loadImage(item))?.cloneNode();
if (copy) {
try {
await copy.decode();
cell.append(copy);
return;
} catch { /* Die übrigen Seiten bleiben unabhängig darstellbar. */ }
}
failures++;
empty(cell, 'Seite zurzeit nicht verfügbar.');
});
}));
if (generation !== this.generation) return;
if (!batch.every(item => this.items.some(entry => entry.url === item.url))) return this.show(0);
this.failed = failures > 0;
// Einen vorhandenen Monatsplan bei defektem Ersatz nicht wegblenden.
if (!this.rotate && failures && this.stage.querySelector('img')) return;
this.current = batch;
this.stage.replaceChildren(sheet);
this.pruneImages();
if (failures === batch.length) empty(this.stage, 'Bilder zurzeit nicht verfügbar.');
this.schedule(); // Mindeststandzeit erst nach vollständigem Laden der Gruppe.
}
}
// Eine Textseite enthält ausschließlich DOM-Text, niemals HTML aus der Datenbank.
function paragraphs(className, entries) {
const container = node('div', className);
for (const [name, text] of entries) if (text) container.append(node('p', name, text));
return container;
}
function announcementList(items) {
const list = node('ul', 'text-page announcement-list');
for (const item of items) {
const entry = node('li', 'announcement-entry');
entry.append(paragraphs('announcement-when', [
['announcement-date', item.date_label || 'Ohne Datum'],
['announcement-end', item.end_label],
]), paragraphs('announcement-content', [
['announcement-title', item.title],
['announcement-subtitle', item.subtitle],
['announcement-meta', [item.time ? item.time + ' Uhr' : '', item.place].filter(Boolean).join(', ')],
]));
list.append(entry);
}
return list;
}
class FixedText {
constructor(id, render = entries => paragraphs('text-page', entries)) {
this.stage = document.getElementById(id).querySelector('.text-stage');
this.render = render;
this.signature = '';
this.loaded = false;
new ResizeObserver(() => this.rebuild()).observe(this.stage);
}
update(documents, message) {
this.loaded = true;
const signature = JSON.stringify([documents, message]);
if (signature === this.signature) return;
this.signature = signature;
if (documents.length) this.stage.replaceChildren(this.render(documents));
else empty(this.stage, message);
this.rebuild();
}
unavailable(message) {
// Auch ein erfolgreich gelesener Leerzustand bleibt bei Abruffehlern stehen.
if (this.loaded) return;
empty(this.stage, message);
this.rebuild();
}
rebuild() {
const page = this.stage.querySelector('.text-page');
this.stage.classList.toggle('is-overflowing', Boolean(page && this.stage.clientHeight
&& page.getBoundingClientRect().height > this.stage.clientHeight + 1));
}
}
const month = new ImageGallery('month-panel', 'Kein Monatsplan vorhanden.');
const posters = new ImageGallery('poster-panel', 'Keine aktuellen Aushänge.', true);
const announcements = new FixedText('announcements-panel', announcementList);
const bible = new FixedText('bible-panel');
let imageData = null;
function layoutImages() {
if (!imageData) return;
// Monatsplan dauerhaft separat: keine Vermischung mit der Aushangrotation.
const months = imageData.month_images;
if (months.length) root.style.setProperty('--month-aspect', aspectRatio(months));
return Promise.all([
month.update(months),
posters.update(imageData.posters.filter(item => item.expires >= currentDay)),
]);
}
function contentLoaded(data) {
if (data.date !== currentDay) return contentUnavailable();
if (data.errors.includes('announcements')) announcements.unavailable('Ansagen zurzeit nicht verfügbar.');
else announcements.update(data.announcements, 'Es sind keine Ansagen eingetragen.');
const service = data.service;
const blocks = service ? [
['bible-reference', service.reference || 'Bibelstelle nicht eingetragen.'],
['bible-verse', service.text || 'Bibelvers nicht eingetragen.'],
] : [];
if (data.errors.includes('service')) bible.unavailable('Bibelwort zurzeit nicht verfügbar.');
else bible.update(blocks, 'Für heute ist kein Bibelwort eingetragen.');
}
function contentUnavailable() {
announcements.unavailable('Ansagen zurzeit nicht verfügbar.');
bible.unavailable('Bibelwort zurzeit nicht verfügbar.');
}
function imagesUnavailable() {
if (!imageData) {
month.update([], 'Monatsplan zurzeit nicht verfügbar.');
posters.update([], 'Aushänge zurzeit nicht verfügbar.');
}
}
// Unabhängige Hintergrundabrufe, ohne Ladezustand und ohne doppelte Anfragen.
function createRefresh(resource, onData, onError) {
let inFlight = null;
return () => inFlight ??= request('data.php?resource=' + resource,
async response => onData(await response.json()), { cache: 'no-store' })
.catch(onError).finally(() => { inFlight = null; });
}
const timeFormat = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit',
});
const dateFormat = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin', day: '2-digit', month: 'long', year: 'numeric',
});
function tick() {
const now = new Date();
const clock = document.getElementById('uhrzeit');
if (clock) {
clock.textContent = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const nextDay = berlinDay(now);
document.getElementById('clock').textContent = timeFormat.format(now);
document.getElementById('clock').dateTime = now.toISOString();
document.getElementById('date').textContent = dateFormat.format(now);
document.getElementById('date').dateTime = nextDay;
if (nextDay !== currentDay) {
currentDay = nextDay;
// Texte erst mit einer erfolgreichen Antwort austauschen; abgelaufene
// Aushänge weiterhin sofort entfernen.
layoutImages();
refreshAll();
}
}
function renderAnnouncements(announcements) {
const container = document.querySelector('.news-scroll');
if (!container || !Array.isArray(announcements)) {
return;
}
container.replaceChildren();
announcements.forEach((announcement) => {
const box = document.createElement('div');
box.className = 'news-box';
const date = document.createElement('div');
date.className = 'news-date';
date.append(document.createTextNode(announcement.date_label || ''));
if (announcement.end_label) {
date.append(document.createElement('br'), document.createTextNode(announcement.end_label));
}
const body = document.createElement('div');
body.append(document.createTextNode(announcement.title || ''));
if (announcement.subtitle) {
const subtitle = document.createElement('div');
subtitle.className = 'news-subtitle';
subtitle.textContent = announcement.subtitle;
body.append(subtitle);
}
if (announcement.meta) {
const meta = document.createElement('div');
meta.className = 'news-subtitle';
const italic = document.createElement('i');
italic.textContent = announcement.meta;
meta.append(italic);
body.append(meta);
}
box.append(date, body);
container.append(box);
});
const refreshContent = createRefresh('content', contentLoaded, contentUnavailable);
const refreshImages = createRefresh('images', data => {
imageData = data;
return layoutImages();
}, imagesUnavailable);
function refreshAll() {
return Promise.all([refreshContent(), refreshImages()]);
}
function renderPosters(posters) {
const container = document.querySelector('.aushang-flex');
if (!container || !Array.isArray(posters)) {
return;
posters.onCycle = refreshAll;
refreshAll();
tick();
setInterval(tick, 1000);
document.fonts.ready.then(() => {
announcements.rebuild();
bible.rebuild();
// Ein Mindestkontrast wird auch bei später geänderten zentralen Tokens geprüft.
const style = getComputedStyle(root);
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const context = canvas.getContext('2d', { willReadFrequently: true });
function luminance(value) {
context.fillStyle = value;
context.fillRect(0, 0, 1, 1);
const channels = Array.from(context.getImageData(0, 0, 1, 1).data).slice(0, 3);
return channels.map(channel => {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}).reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
}
container.replaceChildren();
if (posters.length === 0) {
const empty = document.createElement('p');
empty.style.textAlign = 'left';
empty.textContent = 'Keine Aushänge vorgeplant.';
container.append(empty);
return;
const background = Math.min(...Array.from(document.querySelectorAll(
'.dashboard-panel, .dashboard-overview, .panel-heading, .announcement-entry'),
panel => luminance(getComputedStyle(panel).backgroundColor)));
for (const [property, token] of [['--dashboard-text', '--nak-black'], ['--dashboard-meta', '--gray-700']]) {
const contrast = (background + 0.05) / (luminance(style.getPropertyValue(token)) + 0.05);
if (contrast < config.CONTENT_MIN_CONTRAST_RATIO) root.style.setProperty(property, 'var(--nak-black)');
}
});
posters.forEach((poster) => {
const image = document.createElement('img');
image.src = poster.url;
image.alt = poster.alt || 'Aushang';
image.style.height = '95%';
image.addEventListener('load', () => scheduleScrollingSetup(0), { once: true });
container.append(image);
});
}
function renderMonthImages(images) {
if (!Array.isArray(images)) {
return;
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
tick();
// Nach Rückkehr bekommt die sichtbare Seite erneut ihre volle Lesezeit.
posters.schedule();
}
images.forEach((image) => {
const element = document.querySelector(`[data-month-page="${image.page}"]`);
if (element && image.url && element.getAttribute('src') !== image.url) {
element.src = image.url;
}
});
}
function renderIndoor(indoor) {
if (indoor) {
setText('#temperature_txt', `${indoor.value}${indoor.unit || '°C'}`);
}
}
function renderOutdoor(outdoor) {
const weather = document.getElementById('wetter');
if (!weather || !outdoor) {
return;
}
const icon = WEATHER_ICONS[outdoor.icon] || WEATHER_ICONS.cloudy;
weather.replaceChildren();
const iconContainer = document.createElement('div');
iconContainer.innerHTML = icon;
const temperature = document.createElement('div');
temperature.className = 'text-outdoor';
temperature.textContent = `${outdoor.temperature}°C`;
weather.append(iconContainer, temperature);
}
function renderContent(data) {
setText('[data-dashboard-date]', data.date_label);
if (data.service) {
setText('.bible', data.service.bibelwort);
setText('.verse', data.service.bibelvers);
setText('.GB', data.service.eingang_nummer);
setText('.GB-title', data.service.eingang_titel);
}
renderAnnouncements(data.announcements);
renderPosters(data.posters);
renderMonthImages(data.month_images);
scheduleScrollingSetup();
}
function startPolling(resource, interval, render) {
const refresh = async () => {
try {
const response = await fetch(`data.php?resource=${encodeURIComponent(resource)}`, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
render(data);
} catch (error) {
console.error(`Dashboard-Aktualisierung (${resource}) fehlgeschlagen:`, error);
} finally {
window.setTimeout(refresh, interval);
}
};
refresh();
}
function applyAnimation(element, distance, speed, animationName) {
element.style.animation = 'none';
if (distance <= 0) {
return;
}
const variable = animationName === 'scroll-h' ? '--scroll-distance-h' : '--scroll-distance-v';
element.style.setProperty(variable, `-${distance}px`);
element.style.animationName = animationName;
element.style.animationDuration = `${Math.max(1, distance / speed)}s`;
element.style.animationTimingFunction = 'linear';
element.style.animationIterationCount = 'infinite';
}
function setupScrolling() {
const posterContainer = document.querySelector('.aushang-flex');
if (posterContainer) {
const images = Array.from(posterContainer.querySelectorAll('img'));
const totalWidth = images.reduce((sum, image) => {
const styles = window.getComputedStyle(image);
return sum + image.offsetWidth + (parseFloat(styles.marginRight) || 0);
}, 0);
applyAnimation(
posterContainer,
Math.max(0, totalWidth - posterContainer.offsetWidth),
HORIZONTAL_SPEED_PX_PER_SECOND,
'scroll-h'
);
}
const newsContainer = document.querySelector('.news-container');
const news = document.querySelector('.news-scroll');
if (newsContainer && news) {
applyAnimation(
news,
Math.max(0, news.scrollHeight - newsContainer.offsetHeight),
VERTICAL_SPEED_PX_PER_SECOND,
'scroll-v'
);
}
}
let scrollingTimer = null;
function scheduleScrollingSetup(delay = 80) {
if (scrollingTimer !== null) {
window.clearTimeout(scrollingTimer);
}
scrollingTimer = window.setTimeout(setupScrolling, delay);
}
updateClock();
window.setInterval(updateClock, 1000);
window.addEventListener('load', () => scheduleScrollingSetup(50));
window.addEventListener('resize', () => scheduleScrollingSetup(100));
startPolling('content', CONTENT_REFRESH_MS, renderContent);
startPolling('indoor', INDOOR_REFRESH_MS, (data) => renderIndoor(data.indoor));
startPolling('outdoor', OUTDOOR_REFRESH_MS, (data) => renderOutdoor(data.outdoor));
});
})();
@@ -2,139 +2,65 @@
declare(strict_types=1);
date_default_timezone_set('Europe/Berlin');
require_once __DIR__ . '/util/dashboard_helpers.php';
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
$content = dashboard_fetch_content($pdo, dashboard_now());
$pdo = null;
$service = $content['service'];
$announcements = $content['announcements'];
$posters = $content['posters'];
$monthImages = $content['month_images'];
$stylesheetVersion = (int) filemtime(__DIR__ . '/stylo.css');
$scriptVersion = (int) filemtime(__DIR__ . '/dashboard.js');
// Die Anzeigefläche muss auch ohne Datenbank oder externe APIs starten können.
$config = require __DIR__ . '/config.php';
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NAK Reinickendorf</title>
<link rel="icon" type="image/svg+xml" href="/intranet_repo/projects/_central/img/nak-logo.svg">
<link rel="shortcut icon" href="/intranet_repo/projects/_central/img/favicon.ico">
<link rel="stylesheet" href="stylo.css?v=<?= $stylesheetVersion ?>">
<link href="https://fonts.googleapis.com/css2?family=SUSE:wght@400;700&display=swap" rel="stylesheet">
<script src="dashboard.js?v=<?= $scriptVersion ?>" defer></script>
<meta name="description" content="Aktuelle Informationen der Neuapostolischen Kirche Berlin-Reinickendorf.">
<title>Gemeindeinformationen · NAK Berlin-Reinickendorf</title>
<link rel="icon" type="image/svg+xml" href="../_central/img/nak-logo.svg">
<link rel="stylesheet" href="../_central/layouts/nak-design.css">
<link rel="stylesheet" href="stylo.css?v=<?= (int) filemtime(__DIR__ . '/stylo.css') ?>">
<script id="dashboard-config" type="application/json"><?= json_encode($config, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?></script>
<script src="dashboard.js?v=<?= (int) filemtime(__DIR__ . '/dashboard.js') ?>" defer></script>
</head>
<body>
<div class="content-board">
<div class="content-column-right">
<div class="content-column-right-upper-box">
<div class="content-column-right-upper-box-right">
<div class="content-item-time">
<div class="time"><div id="uhrzeit"></div></div>
<div class="date" style="text-align: center" data-dashboard-date><?= dashboard_html($content['date_label']) ?></div>
</div>
<body class="dashboard">
<main class="dashboard-main" aria-label="Gemeindeinformationen">
<section class="dashboard-panel text-panel" id="announcements-panel" aria-labelledby="announcements-title">
<div class="panel-heading"><h1 id="announcements-title">Ansagen</h1></div>
<div class="text-stage"><p class="empty-state">Ansagen werden geladen …</p></div>
</section>
<div 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 class="community-column">
<section class="dashboard-overview" aria-label="Gemeinde, Datum und Uhrzeit">
<div class="dashboard-brand">
<img src="../_central/img/nak-logo.svg" alt="Neuapostolische Kirche" width="64" height="64">
<div class="dashboard-wordmark">
<span>Neuapostolische Kirche</span>
<strong>Berlin-Reinickendorf</strong>
</div>
</div>
<div class="content-column-right-upper-box-left">
<div class="content-item-middle-top">
<div class="header-sticky-note"><div>Bibelwort</div></div>
<div class="bible"><?= dashboard_html($service['bibelwort']) ?></div>
<div class="verse"><?= dashboard_html($service['bibelvers']) ?></div>
</div>
<div class="content-column-double-item">
<div class="content-item">
<div class="header-sticky-note"><div>Eingangslied</div></div>
<div class="GB"><?= dashboard_html($service['eingang_nummer']) ?></div>
<div class="GB-title"><?= dashboard_html($service['eingang_titel']) ?></div>
</div>
<div class="content-item">
<div class="header-sticky-note"><div>Temperatur</div></div>
<div class="temperature">
<div class="container"><div id="wetter"></div></div>
<div class="container">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 60 60" width="64px" height="64px" baseProfile="basic" aria-hidden="true">
<polygon fill="#66e1ff" points="46,22.11 46,24.42 43,26.15 43,34.24 25,44.63 14,38.28 14,35.97 12,34.81 10,35.97 7,34.24 7,20.38 4,20.96 4,18.65 22,8.26 34,5.95"/>
<polygon fill="#4292a6" points="25,30.774 28,34.815 43,26.155 43,34.238 25,44.63"/>
<polygon fill="#ff5757" points="16,18.649 4,20.959 4,18.649 16,16.34 28,32.506 28,34.815"/>
<polygon fill="#4292a6" points="34,8.257 22,10.566 22,8.257 34,5.947 46,22.113 46,24.423"/>
<polygon fill="#e64e4e" points="4,18.649 22,8.257 34,5.947 16,16.34"/>
<polygon fill="#cc4545" points="28,32.506 46,22.113 34,5.947 16,16.34"/>
<polygon fill="#b33d3d" points="28,32.506 46,22.113 46,24.423 28,34.815"/>
<polygon fill="#4292a6" points="17,34.238 22,37.124 22,31.351 17,28.464"/>
<polygon fill="#4292a6" points="10,35.97 12,34.815 12,27.887 10,26.732"/>
<polygon fill="#4ad8ff" points="19,33.083 22,34.815 22,31.351 19,29.619"/>
<polygon fill="#52b1cc" points="17,34.238 22,37.124 22,34.815 19,33.083"/>
<polygon fill="#ff5757" points="12,34.815 14,35.97 14,29.041 12,27.887"/>
</svg>
<div class="text-indoor" id="temperature_txt"></div>
</div>
</div>
</div>
</div>
<div class="dashboard-clock">
<time id="clock" aria-label="Uhrzeit Europe/Berlin">–:–</time>
<time id="date">Datum wird geladen</time>
</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>
</section>
<section class="dashboard-panel text-panel bible-panel" id="bible-panel" aria-labelledby="bible-title">
<div class="panel-heading"><h2 id="bible-title">Bibelwort für heute</h2></div>
<div class="text-stage"><p class="empty-state">Bibelwort wird geladen …</p></div>
</section>
</div>
<div class="content-column-left">
<div class="content-item-video">
<div class="video-container" aria-label="Leer"></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>
<div class="content-item-month">
<div class="header-sticky-note"><div>Monatsplan</div></div>
<div class="content-item-month-cell">
<?php foreach ($monthImages as $image): ?>
<img src="<?= dashboard_html($image['url']) ?>" data-month-page="<?= (int) $image['page'] ?>" alt="Monatsplan Seite <?= (int) $image['page'] ?>">
<?php endforeach; ?>
</div>
<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>
</div>
<div class="image-stage"><p class="empty-state">Aushänge werden geladen …</p></div>
</section>
</main>
<noscript>Bitte JavaScript aktivieren, damit Uhrzeit, Inhalte und Seitenwechsel angezeigt werden.</noscript>
</body>
</html>
@@ -17,6 +17,12 @@ try {
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
$payload = dashboard_fetch_content($pdo, dashboard_now());
break;
case 'images':
$payload = [
'posters' => dashboard_list_posters(dashboard_now()),
'month_images' => dashboard_month_images(),
];
break;
case 'indoor':
$payload = ['indoor' => dashboard_fetch_indoor_temperature()];
break;
+106 -340
View File
@@ -1,340 +1,106 @@
/* Gesamtes Seiten-Layout */
body {
margin: 0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-family: 'SUSE', sans-serif;
font-size: 20px;
background-image: url('cloudy.jpg');
background-size: cover;
background-repeat: no-repeat;
background-position: center center;
overflow: hidden;
}
/* Content Board Layout */
.content-board {
background-color: #a8a6a7a7;
width: 99%;
height: 97%;
box-sizing: border-box;
display: flex;
justify-content: space-around;
align-items: center;
border-radius: 0;
padding: 0 6px 7px 3px;
}
.content-column-left {
width: 40%;
height: 100%;
box-sizing: border-box;
}
.content-column-right {
width: 58%;
height: 100%;
box-sizing: border-box;
}
.temperature {
display: flex;
justify-content: space-around;
align-items: center;
padding: 5px 0 0 0;
height: 70%;
}
.verse {
text-align: justify;
color: rgb(67, 67, 67);
padding: 5px 15px 5px 15px;
}
.date {
color: rgb(67, 67, 67);
font-weight: bold;
}
.content-column-right-upper-box-left {
width: 60%;
height: 100%;
box-sizing: border-box;
}
.content-column-right-upper-box-right {
width: 39%;
height: 100%;
box-sizing: border-box;
}
.content-column-right-upper-box {
width: 100%;
height: 51%;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
}
.content-item-time {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #ffffffab;
width: 100%;
height: 30%;
margin: 15px 0 15px 0;
box-sizing: border-box;
}
.content-item-news {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #ffffffab;
width: 100%;
height: 63%;
margin: 15px 0 15px 0;
box-sizing: border-box;
}
.content-item-video {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #cbc5bc;
width: 100%;
height: 40%;
margin: 15px 0 15px 0;
box-sizing: border-box;
}
.content-item-middle-top {
position: relative;
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #cbc5bc;
width: 100%;
height: 60%;
margin: 15px 0 15px 0;
box-sizing: border-box;
overflow: hidden;
}
/* Pseudoelement für den animierten border-bottom Effekt */
.content-item-middle-top::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 15px;
background: linear-gradient(to bottom, transparent, #cbc5bc);
}
/* Monatsplan: jetzt mit weißem Hintergrund */
.content-item-month {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #cbc5bc7d;
width: 100%;
height: 56%;
margin: 15px 0 15px 0;
box-sizing: border-box;
overflow: hidden;
}
.content-item-month-cell {
display: flex;
background-color: #ffffffe5;
justify-content: center; /* Zentriere die Bilder horizontal */
height: 94%;
align-items: center; /* Zentriere die Bilder vertikal */
}
.content-item-month-cell img {
max-width: 100%; /* Verhindert, dass das Bild größer als der Container wird */
max-height: 100%; /* Verhindert Verzerrungen, indem es die maximale Höhe auf die Containerhöhe beschränkt */
object-fit: contain; /* Stellt sicher, dass das Bild im Container bleibt, ohne gestreckt zu werden */
}
.content-item-daily {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #edebe8;
width: 100%;
height: 63%;
margin: 15px 0 15px 0;
box-sizing: border-box;
overflow: hidden;
}
.header-sticky-note {
font-size: 25px;
display: flex;
flex-direction: column;
align-items: center;
height: 20px;
width: 100%;
padding: 5px 0 15px 0;
border-bottom: 2px solid #38312430;
background-color: #cbc5bc;
border-radius: 0;
}
.time {
font-size: 80px;
text-align: center;
padding: 8px 0 0 0;
}
.bible {
text-align: center;
font-size: 60px;
padding: 10px 0 10px 0;
border-bottom: 2px solid #38312456;
background-color: #edebe8;
}
.GB {
text-align: center;
font-size: 60px;
padding: 5px 0 0 0;
}
.GB-title {
text-align: center;
color: rgb(67, 67, 67);
}
.content-column-double-item {
width: 100%;
height: 33%;
margin: 15px 0 15px 0;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Elemente innerhalb des Content Boards */
.content-item {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #edebe8;
height: 100%;
width: 49%;
}
.content-item-bottom-box {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #edebe8;
margin: 6px 0 0 0;
height: 47.5%;
width: 100%;
overflow: hidden;
}
.video-container {
border-radius: 0;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
border: 5px solid #cbc5bc;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
}
.video-container video {
width: 100%;
height: 100%;
transform: scale(1.2);
object-fit: cover;
border-radius: 0;
}
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 40%;
background-color: #38312431;
border-radius: 0;
border: 2px solid #38312440;
}
.text-outdoor {
font-weight: bold;
padding: 9px 0 0 0;
margin: 0 0 15px 0;
}
.text-indoor {
font-weight: bold;
margin: 0 0 15px 0;
}
.news-box {
display: flex;
justify-content: flex-start;
padding: 5px 15px 5px 15px;
border-bottom: 2px solid #38312456;
background-color: #edebe8;
word-wrap: break-word;
}
.news-date {
font-weight: bold;
margin: 0 10px 0 0;
}
.news-subtitle {
color: rgba(67, 67, 67, 0.657);
}
.news-container {
height: 290px;
overflow: hidden;
position: relative;
}
.news-scroll {
display: flex;
flex-direction: column;
animation-name: scroll-v;
animation-timing-function: linear;
animation-iteration-count: infinite;
--scroll-distance-v: 0px;
}
@keyframes scroll-v {
0% { transform: translateY(0); }
100% { transform: translateY(var(--scroll-distance-v)); }
}
.aushang-flex img {
border-radius: 0;
margin: 0 10px 0 0;
border: 2px solid #38312440;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.08);
}
.aushang-flex {
padding: 0 5px 0 5px;
display: flex;
justify-content: space-evenly;
align-items: center;
height: 90.5%;
border-radius: 0;
animation-name: scroll-h;
animation-timing-function: linear;
animation-iteration-count: infinite;
--scroll-distance-h: 0px;
}
@keyframes scroll-h {
0% { transform: translateX(0); }
100% { transform: translateX(var(--scroll-distance-h)); }
}
/* Bildgrößen aus dem Original; Schrift und Farben aus den zentralen CD-Tokens. */
body.dashboard {
--dashboard-text: var(--nak-black);
--dashboard-meta: var(--gray-700);
--panel-surface: #fff;
--dashboard-unit: clamp(1px, 0.052083333vw, 2px);
--panel-heading-height: calc(31 * var(--dashboard-unit));
/* Original: Board 97vh minus 7px Innenabstand, Monatsfeld 56%, Bildbereich 94%. */
--original-board-height: calc(97vh - 7px);
--month-image-height: calc(var(--original-board-height) * 0.56 * 0.94);
--month-aspect: 1.414;
--announcement-font: var(--bible-font);
--announcement-detail-font: calc(20 * var(--dashboard-unit));
--announcement-meta-font: calc(15 * var(--dashboard-unit));
--announcement-leading: 1.35;
--announcement-measure: 85ch;
--bible-font: calc(23 * var(--dashboard-unit));
display: block;
height: 100vh;
height: 100dvh;
min-height: 0;
padding: 0;
color: var(--dashboard-text);
background: var(--nak-blue-light) url('cloudy.jpg') center / cover no-repeat;
overflow: hidden;
}
.dashboard [hidden] { display: none !important; }
.dashboard-main {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.85fr) calc(var(--month-image-height) * var(--month-aspect) + 2px);
grid-template-rows: calc(var(--month-image-height) + var(--panel-heading-height) + 2px) minmax(0, 1fr);
grid-template-areas: "community announcements month" "posters posters posters";
gap: var(--space-sm);
height: 100%;
min-height: 0;
padding: var(--space-sm);
}
#month-panel { grid-area: month; --panel-accent: var(--nak-darkblue); }
#poster-panel { grid-area: posters; --panel-accent: var(--nak-orange); --poster-frame-width: 2px; }
#announcements-panel { grid-area: announcements; --panel-accent: var(--nak-teal); }
.community-column { grid-area: community; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: var(--space-sm); min-height: 0; min-width: 0; }
.dashboard-overview { background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--nak-blue); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: calc(12 * var(--dashboard-unit)); padding: calc(8 * var(--dashboard-unit)) calc(12 * var(--dashboard-unit)); }
.dashboard-brand { display: flex; align-items: flex-end; gap: calc(10 * var(--dashboard-unit)); }
.dashboard-brand img { display: block; flex: 0 0 auto; width: calc(64 * var(--dashboard-unit)); height: calc(64 * var(--dashboard-unit)); }
.dashboard-wordmark { font-size: var(--announcement-meta-font); line-height: 1.2; transform: translateY(calc(4 * var(--dashboard-unit))); }
.dashboard-wordmark span, .dashboard-wordmark strong { display: block; white-space: nowrap; }
.dashboard-wordmark strong { font-weight: 500; }
.dashboard-clock { margin-left: auto; transform: translateY(calc(3 * var(--dashboard-unit))); }
.dashboard-clock time { display: block; }
#clock { font-size: calc(48 * var(--dashboard-unit)); line-height: 1; font-weight: 500; font-variant-numeric: tabular-nums; }
#date { font-size: var(--announcement-meta-font); white-space: nowrap; }
.dashboard-panel { min-width: 0; min-height: 0; background: var(--panel-surface); border: 1px solid var(--gray-300); border-radius: 0; }
.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; border-bottom: 1px solid var(--gray-300); background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--panel-accent); }
.panel-heading h1, .panel-heading h2 { margin: 0; color: var(--dashboard-text); font-size: calc(20 * var(--dashboard-unit)); line-height: 1.1; font-weight: 500; }
.poster-progress { width: calc(18 * var(--dashboard-unit)); height: calc(18 * var(--dashboard-unit)); margin-left: auto; flex: 0 0 auto; fill: none; stroke-width: 2; }
.poster-progress-track { stroke: var(--gray-300); }
.poster-progress-value { stroke: var(--panel-accent); stroke-dasharray: 100; stroke-dashoffset: 100; transform: rotate(-90deg); transform-origin: center; }
.image-stage { min-height: 0; min-width: 0; padding: 0; column-gap: var(--space-sm); }
#poster-panel .image-stage { padding: calc(4 * var(--dashboard-unit)); }
.image-sheet { display: flex; justify-content: center; align-items: center; gap: var(--space-sm); width: 100%; height: 100%; min-height: 0; }
#month-panel .image-sheet { gap: 0; }
.image-cell { margin: 0; min-height: 0; min-width: 0; flex: 0 0 auto; }
.poster-document { display: flex; flex: 0 0 auto; gap: 0; background: #fff; border: var(--poster-frame-width) solid var(--gray-500); }
.image-cell img { display: block; width: 100%; height: 100%; object-fit: contain; }
.image-cell .empty-state { padding: var(--space-sm); font-size: var(--announcement-font); }
.text-stage { min-height: 0; margin: calc(6 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)); overflow: hidden; position: relative; }
#announcements-panel .text-stage { margin: 0; }
#announcements-panel .text-stage > .empty-state { padding: 0 calc(10 * var(--dashboard-unit)); }
.text-page { max-width: var(--announcement-measure); color: var(--dashboard-text); }
.text-page p { margin: 0 0 var(--space-xs); white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--announcement-font); line-height: var(--announcement-leading); }
.text-page p:last-child { margin-bottom: 0; }
.text-page .announcement-title, .text-page .bible-reference { font-weight: 500; }
.text-page .announcement-subtitle { font-size: var(--announcement-detail-font); color: var(--dashboard-meta); }
.text-page .announcement-meta { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); }
.announcement-list { max-width: none; margin: 0; padding: 0; list-style: none; }
/* Originale Zeilenaufteilung: Datum links, Inhalt mit Zeit/Ort rechts. */
.announcement-entry { display: grid; grid-template-columns: calc(100 * var(--dashboard-unit)) minmax(0, 1fr); gap: calc(12 * var(--dashboard-unit)); padding: calc(6 * var(--dashboard-unit)) calc(10 * var(--dashboard-unit)); border-bottom: 1px solid var(--gray-300); background: var(--panel-surface); }
.announcement-entry:nth-child(even) { background: var(--gray-50); }
.announcement-content { min-width: 0; }
.announcement-entry p { margin: 0; }
.announcement-content .announcement-meta { font-style: italic; }
.announcement-when { min-width: 0; font-weight: 300; color: var(--dashboard-meta); font-variant-numeric: tabular-nums; }
.announcement-when p { margin-bottom: 0; font-size: var(--announcement-detail-font); }
.bible-panel { --panel-accent: var(--nak-yellow); }
.bible-panel .text-page { max-width: none; }
.text-stage.is-overflowing::after {
content: "";
position: absolute;
inset: auto 0 0;
height: calc(24 * var(--dashboard-unit));
background: linear-gradient(to bottom, transparent, var(--panel-surface));
pointer-events: none;
}
.empty-state { margin: 0; font-size: var(--announcement-font); line-height: var(--announcement-leading); color: var(--dashboard-meta); }
.image-stage > .empty-state { padding: var(--space-md); }
.dashboard noscript { position: fixed; bottom: 0; padding: var(--space-lg); background: #fff; color: var(--nak-red); }
@media (max-width: 999px), (max-height: 599px) {
body.dashboard { height: auto; min-height: 100vh; overflow: visible; }
.dashboard-main {
height: auto;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "community" "announcements" "month" "posters";
grid-template-rows: 520px 320px 65vh 55vh;
}
}
@@ -1,6 +1,9 @@
<?php
require_once __DIR__ . '/../../contentpanel/insertion/util/contentpanel_helpers.php';
declare(strict_types=1);
require_once __DIR__ . '/../../contentpanel/insertion/util/news.php';
require_once __DIR__ . '/../../contentpanel/insertion/util/announcements.php';
require_once __DIR__ . '/../../../general_files/ini/runtime_env.php';
const DASHBOARD_TIMEZONE = 'Europe/Berlin';
@@ -14,141 +17,157 @@ function dashboard_now(): DateTimeImmutable
return new DateTimeImmutable('now', new DateTimeZone(DASHBOARD_TIMEZONE));
}
function dashboard_html($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function dashboard_fetch_content(PDO $pdo, DateTimeImmutable $now): array
{
$now = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE));
$today = $now->format('Y-m-d');
$payload = ['date' => $today, 'service' => null, 'announcements' => [], 'errors' => []];
$serviceStatement = $pdo->prepare(
'SELECT bibelwort, bibelvers, eingang_nummer, eingang_titel
FROM gottesdienst
WHERE bibeldatum = ?'
);
$serviceStatement->execute([$today]);
$service = $serviceStatement->fetch(PDO::FETCH_ASSOC) ?: [];
$announcementStatement = $pdo->prepare(
'SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort, ansage_titel, ansage_untertitel
FROM ansagen
WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ?
ORDER BY ansage_datum_von ASC'
);
$announcementStatement->execute([$today, $today]);
$announcements = $announcementStatement->fetchAll(PDO::FETCH_ASSOC);
foreach ($announcements as &$announcement) {
$announcement = dashboard_prepare_announcement($announcement, $now);
// Unabhängige Abfragen: ein fehlerhafter Bereich blockiert den anderen nicht.
try {
$statement = $pdo->prepare(
'SELECT bibelwort, bibelvers FROM gottesdienst
WHERE bibeldatum = ? ORDER BY id ASC LIMIT 1'
);
$statement->execute([$today]);
$service = $statement->fetch(PDO::FETCH_ASSOC);
if ($service) {
// Leitgedanken-Import: bibelwort = Referenz, bibelvers = eigentlicher Text.
$payload['service'] = [
'reference' => trim((string) ($service['bibelwort'] ?? '')),
'text' => trim((string) ($service['bibelvers'] ?? '')),
];
}
} catch (Throwable $exception) {
error_log('Dashboard Bibelwort: ' . $exception->getMessage());
$payload['errors'][] = 'service';
}
unset($announcement);
contentpanel_cleanup_expired_news();
try {
$statement = $pdo->prepare(
'SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort,
ansage_titel, ansage_untertitel
FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) >= ?
ORDER BY ansage_datum_von ASC NULLS LAST, ansage_uhrzeit ASC NULLS LAST, id ASC'
);
contentpanel_cleanup_expired_announcements($pdo, $today);
$statement->execute([$today]);
foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
$payload['announcements'][] = dashboard_prepare_announcement($row, $now);
}
} catch (Throwable $exception) {
error_log('Dashboard Ansagen: ' . $exception->getMessage());
$payload['errors'][] = 'announcements';
}
return [
'date_label' => dashboard_date_label($now),
'service' => [
'bibelwort' => (string) ($service['bibelwort'] ?? 'Bibelwort nicht eingetragen'),
'bibelvers' => (string) ($service['bibelvers'] ?? 'Bibelvers nicht gefunden.'),
'eingang_nummer' => (string) ($service['eingang_nummer'] ?? 'N/A'),
'eingang_titel' => (string) ($service['eingang_titel'] ?? 'Eingangslied nicht gefunden'),
],
'announcements' => $announcements,
'posters' => dashboard_list_posters(),
'month_images' => dashboard_month_images(),
];
return $payload;
}
function dashboard_date_label(DateTimeImmutable $date): string
function dashboard_date(string $raw): ?DateTimeImmutable
{
static $months = [
1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
return sprintf('%02d. %s', (int) $date->format('d'), $months[(int) $date->format('n')]);
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw, new DateTimeZone(DASHBOARD_TIMEZONE));
return $date && $date->format('Y-m-d') === $raw ? $date : null;
}
function dashboard_prepare_announcement(array $announcement, DateTimeImmutable $now): array
{
$startRaw = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10);
$endRaw = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10);
$timeRaw = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5);
$startDate = DateTimeImmutable::createFromFormat('!Y-m-d', $startRaw, $now->getTimezone());
$tomorrow = $now->setTime(0, 0)->modify('+1 day');
if ($startDate && $startDate->format('Y-m-d') === $now->format('Y-m-d')) {
$dateLabel = 'Heute';
} elseif ($startDate && $startDate->format('Y-m-d') === $tomorrow->format('Y-m-d')) {
$dateLabel = 'Morgen';
} elseif ($startDate) {
static $weekdays = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
$dateLabel = $weekdays[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.');
$now = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE));
$start = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10);
$end = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10);
$today = $now->format('Y-m-d');
$startDate = dashboard_date($start);
$endDate = dashboard_date($end);
if ($start === $today) {
$label = 'Heute';
} elseif ($start === $now->modify('+1 day')->format('Y-m-d')) {
$label = 'Morgen';
} else {
$dateLabel = $startRaw;
$days = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
$label = $startDate ? $days[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.') : '';
}
$endLabel = '';
if ($endRaw !== '') {
$endDate = DateTimeImmutable::createFromFormat('!Y-m-d', $endRaw, $now->getTimezone());
$endLabel = $endDate ? 'bis ' . $endDate->format('d.m.') : 'bis ' . $endRaw;
}
$meta = [];
if ($timeRaw !== '') {
$meta[] = $timeRaw . ' Uhr';
}
$endLabel = $endDate && $end !== $start ? 'bis ' . $endDate->format('d.m.') : '';
$time = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5);
$place = trim((string) ($announcement['ansage_ort'] ?? ''));
if ($place !== '') {
$meta[] = $place;
}
return [
'date_label' => $dateLabel,
'start' => $start,
'end' => $end,
'date_label' => $label,
'end_label' => $endLabel,
'title' => (string) ($announcement['ansage_titel'] ?? ''),
'subtitle' => (string) ($announcement['ansage_untertitel'] ?? ''),
'meta' => implode(', ', $meta),
'time' => $time,
'place' => $place,
'title' => trim((string) ($announcement['ansage_titel'] ?? '')),
'subtitle' => trim((string) ($announcement['ansage_untertitel'] ?? '')),
'meta' => implode(' · ', array_filter([$label, $endLabel, $time !== '' ? $time . ' Uhr' : '', $place])),
];
}
function dashboard_list_posters(): array
function dashboard_image(string $path, string $url): ?array
{
$files = glob(CONTENTPANEL_NEWS_PNG_DIRECTORY . '/*.png') ?: [];
natsort($files);
$posters = [];
foreach ($files as $file) {
if (!is_file($file)) {
continue;
}
$basename = basename($file);
$posters[] = [
'url' => DASHBOARD_NEWS_URL . rawurlencode($basename) . '?v=' . (int) filemtime($file),
'alt' => 'Aushang',
];
if (!is_file($path)) {
return null;
}
return array_values($posters);
$size = @getimagesize($path);
if (!$size || $size[0] < 1 || $size[1] < 1) {
return null;
}
// Neu erzeugte, aber identische PNGs dürfen keinen sichtbaren Austausch auslösen.
$version = @hash_file('sha256', $path);
if ($version === false) {
return null;
}
return [
'url' => $url . '?v=' . $version,
'width' => $size[0],
'height' => $size[1],
];
}
function dashboard_month_images(): array
function dashboard_list_posters(DateTimeImmutable $now, string $directory = CONTENTPANEL_NEWS_PNG_DIRECTORY): array
{
$today = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE))->format('Y-m-d');
$files = glob($directory . '/*.png') ?: [];
natsort($files);
$posters = [];
foreach ($files as $file) {
$name = basename($file);
if (!preg_match('/^(.+)_page_([1-9][0-9]*)\\.png$/D', $name, $match)
|| !contentpanel_is_valid_news_file_base($match[1])) {
continue;
}
// Dieselbe inklusive Ablauffrist wie im Contentpanel. Nur filtern, nicht löschen.
$expiry = substr($match[1], 0, 10);
if (!dashboard_date($expiry) || $expiry < $today) {
continue;
}
$image = dashboard_image($file, DASHBOARD_NEWS_URL . rawurlencode($name));
if ($image) {
$title = str_replace('_', ' ', substr($match[1], 11));
$posters[] = $image + [
'document' => $match[1],
'kind' => 'poster', 'expires' => $expiry, 'page' => (int) $match[2],
'title' => $title, 'alt' => $title . ' · Seite ' . (int) $match[2],
];
}
}
return $posters;
}
function dashboard_month_images(?string $directory = null): array
{
$directory ??= __DIR__ . '/../month';
$images = [];
foreach ([1, 2] as $page) {
$filename = 'terminuebersicht-' . $page . '.png';
$path = __DIR__ . '/../month/' . $filename;
$version = is_file($path) ? (int) filemtime($path) : 0;
$images[] = [
'page' => $page,
'url' => 'month/' . $filename . '?v=' . $version,
];
$image = dashboard_image($directory . '/' . $filename, 'month/' . $filename);
if ($image) {
$images[] = $image + [
'kind' => 'month', 'page' => $page, 'title' => 'Monatsplan',
'alt' => 'Monatsplan · Seite ' . $page,
];
}
}
return $images;
}
@@ -159,17 +178,12 @@ function dashboard_fetch_indoor_temperature(): array
$url = $baseUrl . '/api/states/' . rawurlencode(DASHBOARD_HA_ENTITY_ID);
$data = dashboard_http_json($url, ['Authorization: Bearer ' . $token]);
if (!array_key_exists('state', $data)) {
if (!isset($data['state']) || !is_numeric($data['state'])) {
throw new RuntimeException('Unerwartete Home-Assistant-Antwort.');
}
$value = $data['state'];
if (is_numeric($value)) {
$value = round((float) $value, 1);
}
return [
'value' => $value,
'value' => round((float) $data['state'], 1),
'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'),
];
}
@@ -186,7 +200,6 @@ function dashboard_fetch_outdoor_weather(): array
$data = dashboard_http_json('https://api.openweathermap.org/data/2.5/weather?' . $query);
$temperature = $data['main']['temp'] ?? null;
$icon = (string) ($data['weather'][0]['icon'] ?? '');
if (!is_numeric($temperature)) {
throw new RuntimeException('Unerwartete OpenWeather-Antwort.');
@@ -194,27 +207,9 @@ function dashboard_fetch_outdoor_weather(): array
return [
'temperature' => (int) round((float) $temperature),
'icon' => dashboard_weather_kind($icon),
];
}
function dashboard_weather_kind(string $icon): string
{
return match ($icon) {
'04d', '04n', '03d', '03n' => 'cloudy',
'02d' => 'cloudy_day',
'02n' => 'cloudy_night',
'01d' => 'sun',
'01n' => 'moon',
'09d', '09n', '10n' => 'rain_shower',
'10d' => 'rain',
'11d', '11n' => 'thunder',
'13d', '13n' => 'snowy',
'50d', '50n' => 'mist',
default => 'cloudy',
};
}
function dashboard_http_json(string $url, array $headers = []): array
{
$handle = curl_init($url);
@@ -125,15 +125,28 @@ class WebPreviewTests(unittest.TestCase):
self.page.screenshot(path=str(Path(output) / f'web-{width}.png'), full_page=True, animations='disabled')
self.assertEqual(self.page.locator('script:not([src])').count(), 0)
def test_permissions_and_csrf_reject_mutations(self):
def test_permissions_and_csrf_reject_mutations(self):
denied = self.page.request.post(self.url + '?deny=1', form={'action': 'delete_news', 'file_base': '2030-12-31-Mehrseitig'}, max_redirects=0)
self.assertEqual(denied.status, 303)
self.assertIn('/intranet/index.php', denied.headers['location'])
self.page.request.post(self.url, form={'action': 'delete_news', 'file_base': '2030-12-31-Mehrseitig', 'csrf_token': 'wrong'}, max_redirects=0)
self.page.reload()
self.assertIn('Ungültige Anfrage', self.page.locator('.sb-alert-error').inner_text())
self.assertTrue(all((self.news / f'2030-12-31-Mehrseitig_page_{n}.png').is_file() for n in range(1, 11)))
self.assertTrue(all((self.news / f'2030-12-31-Mehrseitig_page_{n}.png').is_file() for n in range(1, 11)))
def test_page_cleans_expired_announcements_on_each_authorized_request(self):
log = self.root / 'cleanup.jsonl'
before = len(log.read_text().splitlines())
self.page.reload()
cleanup = [json.loads(line) for line in log.read_text().splitlines()]
self.assertEqual(len(cleanup), before + 1)
sql, params = cleanup[-1]
self.assertEqual(sql, 'DELETE FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?')
self.assertEqual(len(params), 1)
self.assertRegex(params[0], r'^\d{4}-\d{2}-\d{2}$')
self.page.request.get(self.url + '?deny=1', max_redirects=0)
self.assertEqual(len(log.read_text().splitlines()), len(cleanup))
def test_forms_preserve_fields_validation_and_bound_sql_parameters(self):
for tab, action, fields in (
('gottesdienst', 'create_service', {'bibeldatum': '2030-12-31', 'bibelwort': 'Joh 3,16', 'bibelvers': 'Text & "Zitat"'}),
@@ -8,8 +8,10 @@ class TestStatement extends PDOStatement
public function __construct(private string $sql) {}
public function execute(?array $params = null): bool
{
if (!str_starts_with($this->sql, 'SELECT')) {
file_put_contents($_SERVER['DOCUMENT_ROOT'] . '/writes.jsonl', json_encode([$this->sql, $params]) . "\n", FILE_APPEND);
if (!str_starts_with($this->sql, 'SELECT')) {
$log = str_contains($this->sql, 'WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?')
? '/cleanup.jsonl' : '/writes.jsonl';
file_put_contents($_SERVER['DOCUMENT_ROOT'] . $log, json_encode([$this->sql, $params]) . "\n", FILE_APPEND);
}
return true;
}