web - refactor core modules, harden authorization, simplify endpoints

This commit is contained in:
2026-08-29 11:15:54 +02:00
parent e1109a07ef
commit 79d857d686
71 changed files with 8269 additions and 9119 deletions
@@ -1,4 +0,0 @@
<?php
require_once __DIR__ . '/../../general_files/ini/runtime_env.php';
define('HA_BASE_URL', rtrim(intranet_env('HA_BASE_URL'), '/'));
define('HA_TOKEN', intranet_env('HA_TOKEN'));
@@ -0,0 +1,236 @@
(() => {
'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 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 setText(selector, value) {
const element = document.querySelector(selector);
if (element) {
element.textContent = value ?? '';
}
}
function updateClock() {
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')}`;
}
}
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);
});
}
function renderPosters(posters) {
const container = document.querySelector('.aushang-flex');
if (!container || !Array.isArray(posters)) {
return;
}
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;
}
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;
}
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));
})();
@@ -1,58 +1,20 @@
<?php
declare(strict_types=1);
date_default_timezone_set('Europe/Berlin');
require_once __DIR__ . '/../contentpanel/insertion/news_cleanup.php';
require_once __DIR__ . '/util/dashboard_helpers.php';
// DB-Verbindung zentral laden (liefert PDO-Objekt zurück)
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
$openWeatherApiKey = intranet_env('OPENWEATHER_API_KEY');
$heute = date('Y-m-d');
// --- Gottesdienst (Bibelwort & Eingangslied) ---
$sql = "SELECT bibelwort, bibelvers, eingang_nummer, eingang_titel
FROM gottesdienst
WHERE bibeldatum = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$heute]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
$bibelwort = $row['bibelwort'];
$bibelvers = $row['bibelvers'];
$eingangNummer = $row['eingang_nummer'];
$eingangTitel = $row['eingang_titel'];
} else {
$bibelwort = "Bibelwort nicht eingetragen";
$bibelvers = "Bibelvers nicht gefunden.";
$eingangNummer = "N/A";
$eingangTitel = "Eingangslied nicht gefunden";
}
// --- Ansagen ---
$sqlAnsagen = "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";
$stmtAnsagen = $pdo->prepare($sqlAnsagen);
$stmtAnsagen->execute([$heute, $heute]);
$ansagenResult = $stmtAnsagen->fetchAll(PDO::FETCH_ASSOC);
// Verbindung schließen (optional, aber sauber)
$content = dashboard_fetch_content($pdo, dashboard_now());
$pdo = null;
// --- Aushänge ---
$pngDirectoryUrl = '../contentpanel/insertion/news/png/';
// Abgelaufene Aushänge einschließlich Original-PDF bereinigen
cleanupExpiredNews();
$pngDirectoryFs = realpath(
__DIR__ . '/../contentpanel/insertion/news/png'
);
$pngFiles = ($pngDirectoryFs && is_dir($pngDirectoryFs))
? scandir($pngDirectoryFs)
: [];
$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>
<html lang="de">
@@ -62,10 +24,9 @@ $pngFiles = ($pngDirectoryFs && is_dir($pngDirectoryFs))
<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=<?php echo time(); ?>">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="stylo.css?v=<?= $stylesheetVersion ?>">
<link href="https://fonts.googleapis.com/css2?family=SUSE:wght@400;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Anton&display=swap" rel="stylesheet">
<script src="dashboard.js?v=<?= $scriptVersion ?>" defer></script>
</head>
<body>
<div class="content-board">
@@ -73,63 +34,29 @@ $pngFiles = ($pngDirectoryFs && is_dir($pngDirectoryFs))
<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">
<center><?php
$months = ["UNDEFINED", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
echo date("d. ") . $months[intval(date("m"))];
?></center>
</div>
<div class="time"><div id="uhrzeit"></div></div>
<div class="date" style="text-align: center" data-dashboard-date><?= dashboard_html($content['date_label']) ?></div>
</div>
<div class="content-item-daily">
<div class="header-sticky-note">
<div>Heutige Ansagen</div>
</div>
<div class="header-sticky-note"><div>Heutige Ansagen</div></div>
<div class="news-container">
<div class="news-scroll">
<?php foreach ($ansagenResult as $ansage) : ?>
<?php foreach ($announcements as $announcement): ?>
<div class="news-box">
<div class="news-date">
<?php
$wochentage = [
'Mon' => 'Mo','Tue' => 'Di','Wed' => 'Mi','Thu' => 'Do',
'Fri' => 'Fr','Sat' => 'Sa','Sun' => 'So'
];
$datum_von_timestamp = strtotime($ansage['ansage_datum_von']);
$heute = date("Y-m-d");
$morgen = date("Y-m-d", strtotime('+1 day'));
$datum_von_format = date("Y-m-d", $datum_von_timestamp);
if ($datum_von_format === $heute) {
echo "Heute";
} elseif ($datum_von_format === $morgen) {
echo "Morgen";
} else {
$datum_von = date("d.m.", $datum_von_timestamp);
$wochentag_englisch = date("D", $datum_von_timestamp);
$wochentag = $wochentage[$wochentag_englisch] ?? $wochentag_englisch;
echo $wochentag . '&nbsp;' . $datum_von;
}
if (!empty($ansage['ansage_datum_bis'])) {
$datum_bis = date("d.m.", strtotime($ansage['ansage_datum_bis']));
echo "<br> bis " . $datum_bis;
}
?>
<?= dashboard_html($announcement['date_label']) ?>
<?php if ($announcement['end_label'] !== ''): ?>
<br><?= dashboard_html($announcement['end_label']) ?>
<?php endif; ?>
</div>
<div>
<?php echo htmlspecialchars($ansage['ansage_titel']); ?>
<?php if (!empty($ansage['ansage_untertitel'])) : ?>
<div class="news-subtitle"><?php echo htmlspecialchars($ansage['ansage_untertitel']); ?></div>
<?= dashboard_html($announcement['title']) ?>
<?php if ($announcement['subtitle'] !== ''): ?>
<div class="news-subtitle"><?= dashboard_html($announcement['subtitle']) ?></div>
<?php endif; ?>
<?php if (!empty($ansage['ansage_uhrzeit']) && !empty($ansage['ansage_ort'])) : ?>
<div class="news-subtitle"><i><?php echo date("H:i", strtotime($ansage['ansage_uhrzeit'])) . ' Uhr'; ?>, <?php echo htmlspecialchars($ansage['ansage_ort']); ?></i></div>
<?php elseif (!empty($ansage['ansage_uhrzeit'])) : ?>
<div class="news-subtitle"><i><?php echo date("H:i", strtotime($ansage['ansage_uhrzeit'])) . ' Uhr'; ?></i></div>
<?php elseif (!empty($ansage['ansage_ort'])) : ?>
<div class="news-subtitle"><i><?php echo htmlspecialchars($ansage['ansage_ort']); ?></i></div>
<?php if ($announcement['meta'] !== ''): ?>
<div class="news-subtitle"><i><?= dashboard_html($announcement['meta']) ?></i></div>
<?php endif; ?>
</div>
</div>
@@ -141,32 +68,24 @@ $pngFiles = ($pngDirectoryFs && is_dir($pngDirectoryFs))
<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"><?php echo $bibelwort; ?></div>
<div class="verse"><?php echo $bibelvers; ?></div>
<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"><?php echo $eingangNummer; ?></div>
<div class="GB-title"><?php echo $eingangTitel; ?></div>
<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="header-sticky-note"><div>Temperatur</div></div>
<div class="temperature">
<div class="container"><div id="wetter"></div></div>
<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">
<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"/>
@@ -180,333 +99,42 @@ $pngFiles = ($pngDirectoryFs && is_dir($pngDirectoryFs))
<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 class="text-indoor" id="temperature_txt"></div>
</div>
</div>
</div>
</div>
</div> <!-- /left upper box -->
</div> <!-- /right upper -->
<div class="content-item-bottom-box">
<div class="header-sticky-note">
<div>Aushänge</div>
</div>
<div class="aushang-flex">
<?php
$hasPng = false;
foreach ($pngFiles as $file) {
if (pathinfo($file, PATHINFO_EXTENSION) === 'png') {
$hasPng = true;
echo '<img src="' . $pngDirectoryUrl . $file . '" alt="Aushang" height="95%">';
}
}
if (!$hasPng) {
echo '<p style="text-align: left;">Keine Aushänge vorgeplant.</p>';
}
?>
</div>
</div>
</div> <!-- /right column -->
<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 class="content-column-left">
<div class="content-item-video">
<!-- leerer Container, kein Video/HLS -->
<div class="video-container" aria-label="Leer"></div>
</div>
<div class="content-item-month">
<div class="header-sticky-note">
<div>Monatsplan</div>
</div>
<div class="header-sticky-note"><div>Monatsplan</div></div>
<div class="content-item-month-cell">
<img src="month/terminuebersicht-1.png?v=<?php echo time(); ?>" alt="image">
<img src="month/terminuebersicht-2.png?v=<?php echo time(); ?>" alt="image">
<?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> <!-- /left column -->
</div> <!-- /content-board -->
<script>
function updateClock() {
var now = new Date();
var uhrzeitElement = document.getElementById("uhrzeit");
var stunden = now.getHours();
var minuten = now.getMinutes();
uhrzeitElement.innerHTML = + padZero(stunden) + ':' + padZero(minuten);
}
function padZero(number) { return number < 10 ? '0' + number : number; }
updateClock();
setInterval(updateClock, 1000);
// OpenWeather (Außentemperatur+Icon)
const apiUrl = `https://api.openweathermap.org/data/2.5/weather?lat=52.571291&lon=13.310422&appid=<?= rawurlencode($openWeatherApiKey) ?>&units=metric&language=german`;
// Wetter-Icons
const cloudy = `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true">
<defs>
<filter id="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(#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 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>`;
const 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>`;
const 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>`;
const 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>`;
const 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>`;
const 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>`;
const 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>`;
const 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>`;
const 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>`;
async function getWeather() {
try {
var response = await fetch(apiUrl);
var data = await response.json();
if (data.cod === 200) {
var weatherIcon = data.weather[0].icon;
var temperature = data.main.temp;
var weatherElement = document.getElementById('wetter');
if (["04d","04n","03d","03n"].includes(weatherIcon)) {
weatherElement.innerHTML = '<div>' + cloudy + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (weatherIcon === "02d") {
weatherElement.innerHTML = '<div>' + cloudy_day + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (weatherIcon === "02n") {
weatherElement.innerHTML = '<div>' + cloudy_night + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (weatherIcon === "01d") {
weatherElement.innerHTML = '<div>' + sun + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (weatherIcon === "01n") {
weatherElement.innerHTML = '<div>' + moon + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (["09d","09n","10n"].includes(weatherIcon)) {
weatherElement.innerHTML = '<div>' + rain_shower + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (weatherIcon === "10d") {
weatherElement.innerHTML = '<div>' + rain + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (["11d","11n"].includes(weatherIcon)) {
weatherElement.innerHTML = '<div>' + thunder + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (["13d","13n"].includes(weatherIcon)) {
weatherElement.innerHTML = '<div>' + snowy + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
} else if (["50d","50n"].includes(weatherIcon)) {
weatherElement.innerHTML = '<div>' + mist + `</div><div class="text-outdoor"><center>${Math.round(temperature)}°C</center></div>`;
}
} else {
console.error('Fehler beim Abrufen des Wetters:', data.message);
}
} catch (error) {
console.error('Fehler beim Abrufen des Wetters:', error.message);
}
}
getWeather();
setInterval(getWeather, 30000);
// INNENTEMPERATUR: jetzt über Home Assistant (Server-Proxy)
function updateTemperature() {
const entityId = 'sensor.temp_temperatur';
fetch('ha_temperature.php?entity_id=' + encodeURIComponent(entityId))
.then(r => r.json())
.then(data => {
if (data.error) {
console.error('HA Fehler:', data);
return;
}
document.getElementById('temperature_txt').innerText = `${data.value}${data.unit || '°C'}`;
})
.catch(err => console.error('Fetch Fehler:', err));
}
updateTemperature();
setInterval(updateTemperature, 10000);
function updateContent() {
fetch('update_content.php')
.then(response => response.json())
.then(data => {
if (data.error) {
console.error('Fehler:', data.error);
return;
}
document.querySelector('.bible').innerText = data.bibelwort;
document.querySelector('.verse').innerText = data.bibelvers;
document.querySelector('.GB').innerText = data.eingang_nummer;
document.querySelector('.GB-title').innerText = data.eingang_titel;
setupScrolling();
})
.catch(error => {
console.error('Fehler beim Abrufen der Daten:', error);
});
}
updateContent();
setInterval(updateContent, 30000);
function setupScrolling() {
const SPEED_V = 7; // News vertikal
const SPEED_H = 10; // Aushänge horizontal
const containerFlex = document.querySelector('.aushang-flex');
if (containerFlex) {
const images = containerFlex.querySelectorAll('img');
const measureAndApplyH = () => {
let totalWidth = 0;
images.forEach(img => {
const styles = window.getComputedStyle(img);
const mr = parseFloat(styles.marginRight) || 0;
totalWidth += (img.offsetWidth || 0) + mr;
});
const containerWidth = containerFlex.offsetWidth || 0;
const scrollDistanceH = Math.max(0, totalWidth - containerWidth);
containerFlex.style.setProperty('--scroll-distance-h', `-${scrollDistanceH}px`);
const durationH = Math.max(1, scrollDistanceH / SPEED_H);
if (scrollDistanceH === 0) {
containerFlex.style.animation = 'none';
} else {
containerFlex.style.animationName = 'scroll-h';
containerFlex.style.animationDuration = `${durationH}s`;
containerFlex.style.animationTimingFunction = 'linear';
containerFlex.style.animationIterationCount = 'infinite';
}
};
let pending = 0;
images.forEach(img => {
if (!img.complete || img.naturalWidth === 0) {
pending++;
img.addEventListener('load', () => {
pending--;
if (pending === 0) measureAndApplyH();
}, { once: true });
}
});
if (pending === 0) measureAndApplyH();
}
const containerNews = document.querySelector('.news-container');
const scrollBox = document.querySelector('.news-scroll');
if (containerNews && scrollBox) {
const totalHeight = scrollBox.scrollHeight || 0;
const visibleHeight = containerNews.offsetHeight || 0;
const scrollDistanceV = Math.max(0, totalHeight - visibleHeight);
scrollBox.style.setProperty('--scroll-distance-v', `-${scrollDistanceV}px`);
const durationV = Math.max(1, scrollDistanceV / SPEED_V);
if (scrollDistanceV === 0) {
scrollBox.style.animation = 'none';
} else {
scrollBox.style.animationName = 'scroll-v';
scrollBox.style.animationDuration = `${durationV}s`;
scrollBox.style.animationTimingFunction = 'linear';
scrollBox.style.animationIterationCount = 'infinite';
}
}
}
window.addEventListener('load', () => {
const news = document.querySelector('.news-scroll');
const aushang = document.querySelector('.aushang-flex');
if (news) news.style.animation = 'none';
if (aushang) aushang.style.animation = 'none';
setTimeout(setupScrolling, 50);
});
window.addEventListener('resize', () => {
const news = document.querySelector('.news-scroll');
const aushang = document.querySelector('.aushang-flex');
if (news) news.style.animation = 'none';
if (aushang) aushang.style.animation = 'none';
setTimeout(setupScrolling, 100);
});
</script>
</div>
</div>
</body>
</html>
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
date_default_timezone_set('Europe/Berlin');
require_once __DIR__ . '/util/dashboard_helpers.php';
$resource = (string) ($_GET['resource'] ?? 'content');
$pdo = null;
try {
switch ($resource) {
case 'content':
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
$payload = dashboard_fetch_content($pdo, dashboard_now());
break;
case 'indoor':
$payload = ['indoor' => dashboard_fetch_indoor_temperature()];
break;
case 'outdoor':
$payload = ['outdoor' => dashboard_fetch_outdoor_weather()];
break;
default:
http_response_code(400);
$payload = ['error' => 'Unbekannte Dashboard-Ressource.'];
}
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} catch (Throwable $exception) {
http_response_code(502);
error_log('Dashboard data endpoint (' . $resource . '): ' . $exception->getMessage());
echo json_encode(['error' => 'Dashboard-Daten konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE);
} finally {
$pdo = null;
}
@@ -1,60 +0,0 @@
<?php
header('Content-Type: application/json; charset=utf-8');
// Token/URL aus externer Config laden
require_once __DIR__ . '/config.local.php';
// Entity-ID (Standard: dein Sensor aus der Liste)
$entityId = isset($_GET['entity_id']) ? $_GET['entity_id'] : 'sensor.temp_temperatur';
$url = rtrim(HA_BASE_URL, '/') . '/api/states/' . urlencode($entityId);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . HA_TOKEN,
'Content-Type: application/json'
],
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 5,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
http_response_code(502);
echo json_encode([
'error' => 'Home Assistant request failed',
'details' => $error ?: ('HTTP ' . $httpCode)
]);
exit;
}
$data = json_decode($response, true);
if (!is_array($data) || !isset($data['state'])) {
http_response_code(500);
echo json_encode(['error' => 'Unexpected HA response']);
exit;
}
$val = $data['state'];
$unit = $data['attributes']['unit_of_measurement'] ?? '°C';
$CALIBRATION_OFFSET = +0;
if (is_numeric($val)) {
$number = round((float)$val + $CALIBRATION_OFFSET, 1);
} else {
$number = $val;
}
echo json_encode([
'entity_id' => $entityId,
'value' => $number,
'unit' => $unit,
'friendly_name' => $data['attributes']['friendly_name'] ?? null,
'last_changed' => $data['last_changed'] ?? null,
]);
@@ -1,54 +0,0 @@
<?php
header('Content-Type: application/json; charset=utf-8');
date_default_timezone_set('Europe/Berlin');
// DB-Verbindung zentral laden
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
$heute = date('Y-m-d');
try {
// --- Bibelwort, Bibelvers und Eingangslied ---
$sql = "SELECT bibelwort, bibelvers, eingang_nummer, eingang_titel
FROM gottesdienst
WHERE bibeldatum = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$heute]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
$bibelwort = $row['bibelwort'];
$bibelvers = $row['bibelvers'];
$eingangNummer = $row['eingang_nummer'];
$eingangTitel = $row['eingang_titel'];
} else {
$bibelwort = "Bibelwort nicht eingetragen";
$bibelvers = "Bibelvers nicht gefunden.";
$eingangNummer = "N/A";
$eingangTitel = "Eingangslied nicht gefunden";
}
// --- Ansagen ---
$sqlAnsagen = "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";
$stmtAnsagen = $pdo->prepare($sqlAnsagen);
$stmtAnsagen->execute([$heute, $heute]);
$ansagenResult = $stmtAnsagen->fetchAll(PDO::FETCH_ASSOC);
// Rückgabe als JSON
echo json_encode([
'bibelwort' => $bibelwort,
'bibelvers' => $bibelvers,
'eingang_nummer' => $eingangNummer,
'eingang_titel' => $eingangTitel,
'ansagen' => $ansagenResult
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
// Optional: error_log($e->getMessage());
echo json_encode(['error' => 'Fehler bei der Verbindung zur Datenbank'], JSON_UNESCAPED_UNICODE);
} finally {
$pdo = null;
}
@@ -0,0 +1,257 @@
<?php
require_once __DIR__ . '/../../contentpanel/insertion/util/contentpanel_helpers.php';
require_once __DIR__ . '/../../../general_files/ini/runtime_env.php';
const DASHBOARD_TIMEZONE = 'Europe/Berlin';
const DASHBOARD_HA_ENTITY_ID = 'sensor.temp_temperatur';
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571291';
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.310422';
const DASHBOARD_NEWS_URL = '/intranet_repo/projects/contentpanel/insertion/news/png/';
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
{
$today = $now->format('Y-m-d');
$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);
}
unset($announcement);
contentpanel_cleanup_expired_news();
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(),
];
}
function dashboard_date_label(DateTimeImmutable $date): string
{
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')]);
}
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.');
} else {
$dateLabel = $startRaw;
}
$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';
}
$place = trim((string) ($announcement['ansage_ort'] ?? ''));
if ($place !== '') {
$meta[] = $place;
}
return [
'date_label' => $dateLabel,
'end_label' => $endLabel,
'title' => (string) ($announcement['ansage_titel'] ?? ''),
'subtitle' => (string) ($announcement['ansage_untertitel'] ?? ''),
'meta' => implode(', ', $meta),
];
}
function dashboard_list_posters(): 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',
];
}
return array_values($posters);
}
function dashboard_month_images(): array
{
$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,
];
}
return $images;
}
function dashboard_fetch_indoor_temperature(): array
{
$baseUrl = rtrim(intranet_env('HA_BASE_URL'), '/');
$token = intranet_env('HA_TOKEN');
$url = $baseUrl . '/api/states/' . rawurlencode(DASHBOARD_HA_ENTITY_ID);
$data = dashboard_http_json($url, ['Authorization: Bearer ' . $token]);
if (!array_key_exists('state', $data)) {
throw new RuntimeException('Unerwartete Home-Assistant-Antwort.');
}
$value = $data['state'];
if (is_numeric($value)) {
$value = round((float) $value, 1);
}
return [
'value' => $value,
'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'),
];
}
function dashboard_fetch_outdoor_weather(): array
{
$query = http_build_query([
'lat' => DASHBOARD_OPENWEATHER_LATITUDE,
'lon' => DASHBOARD_OPENWEATHER_LONGITUDE,
'appid' => intranet_env('OPENWEATHER_API_KEY'),
'units' => 'metric',
'lang' => 'de',
], '', '&', PHP_QUERY_RFC3986);
$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.');
}
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);
if ($handle === false) {
throw new RuntimeException('HTTP-Anfrage konnte nicht initialisiert werden.');
}
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $headers),
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 5,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
]);
try {
$response = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
$error = curl_error($handle);
} finally {
curl_close($handle);
}
if ($response === false || $status < 200 || $status >= 300) {
throw new RuntimeException($error !== '' ? $error : 'HTTP ' . $status);
}
try {
$data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new RuntimeException('Ungültige JSON-Antwort.', 0, $exception);
}
if (!is_array($data)) {
throw new RuntimeException('Unerwartete JSON-Antwort.');
}
return $data;
}