initial commit

This commit is contained in:
TheOnlyMace
2026-06-18 23:45:08 +02:00
commit 7ba9687619
96 changed files with 4309 additions and 0 deletions

17
frontend/502.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>HexaWetter Dienst nicht erreichbar</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<main class="legal-page">
<h1>Dienst vorübergehend nicht erreichbar</h1>
<p>Die HexaWetter-API antwortet gerade nicht. Bitte später erneut versuchen oder den Stack prüfen:</p>
<pre>docker compose ps
docker compose logs api</pre>
<p><a href="/">Zur Startseite</a></p>
</main>
</body>
</html>

89
frontend/admin.html Normal file
View File

@@ -0,0 +1,89 @@
<!doctype html>
<html lang="de" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Admin HexaWetter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="style.css" />
</head>
<body class="admin-page">
<header class="site-header">
<div class="container header-inner">
<a class="brand-link" href="index.html">
<div class="brand-logo">H</div>
<div>
<div class="brand-sub">HexaWetter</div>
<div class="brand-title">Admin</div>
</div>
</a>
<div class="header-actions">
<a href="index.html" class="btn btn-secondary">Zurück</a>
<button id="logoutBtn" class="btn btn-ghost admin-hidden">Abmelden</button>
</div>
</div>
</header>
<main class="app-main">
<div class="container">
<section id="loginSection" class="admin-login-wrap">
<div class="glass-card admin-login-card">
<h1>Admin Login</h1>
<p class="muted">Geschützter Bereich für Service-Status und Einstellungen.</p>
<form id="loginForm">
<label>Benutzername<input id="adminUser" autocomplete="username" required /></label>
<label>Passwort<input id="adminPass" type="password" autocomplete="current-password" required minlength="8" /></label>
<button class="btn btn-primary full-width" type="submit" style="margin-top:12px">Anmelden</button>
</form>
<p id="loginError" class="admin-error"></p>
</div>
</section>
<section id="dashboardSection" class="admin-hidden">
<div class="admin-toolbar">
<div>
<h1>Dashboard</h1>
<p class="muted" id="adminTime"></p>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button id="refreshBtn" class="btn btn-secondary">Aktualisieren</button>
<button id="clearCacheBtn" class="btn btn-secondary">Redis-Cache leeren</button>
</div>
</div>
<section class="admin-grid" id="infraGrid"></section>
<section class="glass-card" style="margin-top:16px">
<h2>Dienste & Datenquellen</h2>
<div class="source-status-grid" id="serviceGrid"></div>
</section>
<section class="glass-card">
<h2>Speicher</h2>
<div class="admin-grid" id="storageGrid"></div>
</section>
<section class="glass-card">
<h2>Konfiguration</h2>
<pre class="config-block" id="configBlock"></pre>
</section>
<section class="glass-card">
<h2>Runtime-Einstellungen</h2>
<form id="settingsForm" class="settings-grid">
<label>Rate-Limit / Minute<input id="setRateLimit" type="number" min="10" max="1000" /></label>
<label>Cache Forecast (s)<input id="setCacheForecast" type="number" min="60" max="86400" /></label>
<label>Cache Observations (s)<input id="setCacheObs" type="number" min="60" max="86400" /></label>
<label>Cache Warnings (s)<input id="setCacheWarn" type="number" min="30" max="3600" /></label>
<label>Radar-Retention (h)<input id="setRadarRetention" type="number" min="6" max="168" /></label>
<div><button class="btn btn-primary full-width" type="submit">Speichern</button></div>
</form>
<p class="muted" id="settingsMsg" style="margin-top:10px"></p>
</section>
</section>
</div>
</main>
<script src="admin.js"></script>
</body>
</html>

169
frontend/admin.js Normal file
View File

@@ -0,0 +1,169 @@
const TOKEN_KEY = "hexawetter_admin_token";
const el = (id) => document.getElementById(id);
function getToken() {
return sessionStorage.getItem(TOKEN_KEY);
}
function setToken(token) {
if (token) sessionStorage.setItem(TOKEN_KEY, token);
else sessionStorage.removeItem(TOKEN_KEY);
}
async function api(path, options = {}) {
const headers = { ...(options.headers || {}) };
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(path, { ...options, headers });
if (res.status === 401) {
setToken(null);
showLogin();
throw new Error("Sitzung abgelaufen");
}
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text}`);
}
return res.json();
}
function formatBytes(bytes) {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
let v = bytes, u = 0;
while (v >= 1024 && u < units.length - 1) { v /= 1024; u++; }
return `${v.toFixed(u === 0 ? 0 : 1)} ${units[u]}`;
}
function showLogin() {
el("loginSection").classList.remove("admin-hidden");
el("dashboardSection").classList.add("admin-hidden");
el("logoutBtn").classList.add("admin-hidden");
}
function showDashboard() {
el("loginSection").classList.add("admin-hidden");
el("dashboardSection").classList.remove("admin-hidden");
el("logoutBtn").classList.remove("admin-hidden");
}
function renderServices(services) {
el("serviceGrid").innerHTML = (services || []).map((s) => `
<div class="source-status ${s.status}">
<strong>${s.name}</strong>
<span>${s.status}${s.latency_ms ? ` · ${s.latency_ms} ms` : ""}</span>
${s.http_status ? `<small>HTTP ${s.http_status}</small>` : ""}
${s.error ? `<small>${s.error}</small>` : ""}
</div>
`).join("");
}
function renderStorage(storage) {
el("storageGrid").innerHTML = Object.entries(storage || {}).map(([key, info]) => `
<div class="admin-stat">
<strong>${formatBytes(info.bytes || 0)}</strong>
<span>${key}: ${info.files || 0} Dateien</span>
</div>
`).join("");
}
function renderInfra(infra) {
el("infraGrid").innerHTML = Object.entries(infra || {}).map(([key, status]) => `
<div class="admin-stat">
<strong>${status}</strong>
<span>${key}</span>
</div>
`).join("");
}
async function loadDashboard() {
const data = await api("/api/admin/dashboard");
el("adminTime").textContent = `Stand: ${new Date(data.time_utc).toLocaleString("de-DE")}`;
renderInfra(data.infrastructure);
renderServices(data.services);
renderStorage(data.storage);
el("configBlock").textContent = JSON.stringify(data.config, null, 2);
const settings = await api("/api/admin/settings");
const defaults = settings.defaults;
const overrides = settings.runtime_overrides || {};
el("setRateLimit").value = overrides.rate_limit_per_minute ?? defaults.rate_limit_per_minute;
el("setCacheForecast").value = overrides.cache_ttl_forecast ?? defaults.cache_ttl.forecast;
el("setCacheObs").value = overrides.cache_ttl_observations ?? defaults.cache_ttl.observations;
el("setCacheWarn").value = overrides.cache_ttl_warnings ?? defaults.cache_ttl.warnings;
el("setRadarRetention").value = overrides.radar_retention_hours ?? defaults.radar_retention_hours;
}
el("loginForm").addEventListener("submit", async (e) => {
e.preventDefault();
el("loginError").textContent = "";
try {
const data = await api("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: el("adminUser").value, password: el("adminPass").value }),
});
setToken(data.access_token);
showDashboard();
await loadDashboard();
} catch (err) {
el("loginError").textContent = err.message.includes("401") ? "Ungültige Zugangsdaten" : err.message;
}
});
el("logoutBtn").addEventListener("click", () => {
setToken(null);
showLogin();
});
el("refreshBtn").addEventListener("click", () => loadDashboard().catch((e) => alert(e.message)));
el("clearCacheBtn").addEventListener("click", async () => {
if (!confirm("Redis-Cache wirklich leeren?")) return;
await api("/api/admin/cache/clear", { method: "POST" });
alert("Cache geleert");
await loadDashboard();
});
el("settingsForm").addEventListener("submit", async (e) => {
e.preventDefault();
const payload = {
rate_limit_per_minute: Number(el("setRateLimit").value),
cache_ttl_forecast: Number(el("setCacheForecast").value),
cache_ttl_observations: Number(el("setCacheObs").value),
cache_ttl_warnings: Number(el("setCacheWarn").value),
radar_retention_hours: Number(el("setRadarRetention").value),
};
try {
await api("/api/admin/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
el("settingsMsg").textContent = "Einstellungen in Redis gespeichert.";
} catch (err) {
el("settingsMsg").textContent = err.message;
}
});
async function init() {
try {
const status = await fetch("/api/admin/status").then((r) => r.json());
if (!status.admin_enabled) {
el("loginError").textContent = "Admin ist nicht konfiguriert. Bitte ADMIN_PASSWORD_HASH und ADMIN_JWT_SECRET in .env setzen.";
return;
}
} catch { /* ignore */ }
if (getToken()) {
try {
showDashboard();
await loadDashboard();
return;
} catch {
setToken(null);
}
}
showLogin();
}
init();

671
frontend/app.js Normal file
View File

@@ -0,0 +1,671 @@
const STORAGE_KEY = "hexawetter_settings";
const FAV_KEY = "hexawetter_favorites";
const el = (id) => document.getElementById(id);
let latestForecast = null;
let map = null;
let mapMarker = null;
let wmsLayers = [];
let layerControl = null;
let osmLayer = null;
let radarLayer = null;
let warningLayer = null;
let localRadarOverlay = null;
let radarTimes = [];
let radarFrameIndex = 0;
let radarPlayTimer = null;
let radarPlaying = false;
let renderedFrames = [];
let settings = loadSettings();
let searchTimer = null;
const WEATHER_ICONS = {
0: "☀️", 1: "🌤️", 2: "⛅", 3: "☁️", 45: "🌫️", 48: "🌫️",
51: "🌦️", 53: "🌦️", 55: "🌧️", 61: "🌧️", 63: "🌧️", 65: "🌧️",
71: "🌨️", 73: "🌨️", 75: "❄️", 80: "🌦️", 81: "🌧️", 82: "⛈️",
95: "⛈️", 96: "⛈️", 99: "⛈️",
};
function loadSettings() {
try {
return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}") };
} catch { return { theme: "dark", unitTemp: "celsius", unitWind: "kmh", radarMinutes: 120 }; }
}
function saveSettings() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
function loadFavorites() {
try { return JSON.parse(localStorage.getItem(FAV_KEY) || "[]"); } catch { return []; }
}
function saveFavorites(items) {
localStorage.setItem(FAV_KEY, JSON.stringify(items));
}
function formatBytes(bytes) {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let value = bytes, unit = 0;
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; }
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function weatherText(code) {
const map = {
0: "Klar", 1: "Überwiegend klar", 2: "Teils bewölkt", 3: "Bewölkt",
45: "Nebel", 48: "Reifnebel", 51: "Leichter Niesel", 53: "Niesel",
55: "Starker Niesel", 61: "Leichter Regen", 63: "Regen", 65: "Starker Regen",
71: "Leichter Schnee", 73: "Schnee", 75: "Starker Schnee", 80: "Regenschauer",
81: "Starke Schauer", 82: "Heftige Schauer", 95: "Gewitter", 96: "Gewitter mit Hagel", 99: "Schweres Gewitter",
};
return `${WEATHER_ICONS[code] || ""} ${map[code] || `Code ${code}`}`.trim();
}
function convertTemp(c) {
if (settings.unitTemp === "fahrenheit") return `${Math.round(c * 9 / 5 + 32)} °F`;
return `${Math.round(c)} °C`;
}
function convertWind(kmh) {
if (settings.unitWind === "ms") return `${(kmh / 3.6).toFixed(1)} m/s`;
if (settings.unitWind === "kn") return `${(kmh * 0.539957).toFixed(1)} kn`;
return `${Math.round(kmh)} km/h`;
}
async function api(path, options = {}) {
const res = await fetch(path, options);
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status}: ${text}`);
}
return res.json();
}
function getLocation() {
return {
place: el("place").value || "Standort",
lat: Number(el("lat").value),
lon: Number(el("lon").value),
};
}
function setLocation(loc) {
el("place").value = loc.place;
el("lat").value = loc.lat;
el("lon").value = loc.lon;
}
function formatDateTime(value) {
return new Date(value).toLocaleString("de-DE", {
weekday: "short", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
});
}
function metric(label, value, hint = "") {
return `<div class="metric"><span>${label}</span><strong>${value}</strong>${hint ? `<small>${hint}</small>` : ""}</div>`;
}
function warnLevelClass(level) {
if (level >= 50) return "warn-extreme";
if (level >= 40) return "warn-severe";
if (level >= 30) return "warn-moderate";
if (level >= 20) return "warn-minor";
return "warn-info";
}
async function loadHealth() {
try {
const data = await api("/api/health");
el("health").textContent = `API: online · v${data.version} · ${new Date(data.time_utc).toLocaleTimeString("de-DE")}`;
el("health").classList.remove("bad");
} catch {
el("health").textContent = "API: offline";
el("health").classList.add("bad");
}
}
async function loadSourceStatus() {
try {
const data = await api("/api/health/sources");
renderSourceStatus(data.sources || []);
} catch {
el("sourceStatusSummary").textContent = "Quellenstatus nicht verfügbar";
}
}
function applyDashboard(dash) {
const loc = getLocation();
if (dash.forecast?.data) {
latestForecast = dash.forecast;
const current = dash.forecast.data.current;
el("currentTemp").textContent = convertTemp(current.temperature_2m);
el("currentMeta").textContent = `${weatherText(current.weather_code)} · Wind ${convertWind(current.wind_speed_10m)} · ${loc.place}`;
el("forecastSource").textContent = dash.forecast.cached ? `${dash.forecast.source} (Cache)` : dash.forecast.source;
el("dailySource").textContent = dash.forecast.cached ? `${dash.forecast.source} (Cache)` : dash.forecast.source;
el("currentDetails").innerHTML = [
metric("Gefühlt", convertTemp(current.apparent_temperature)),
metric("Luftfeuchte", `${current.relative_humidity_2m} %`),
metric("Luftdruck", `${Math.round(current.pressure_msl)} hPa`),
metric("Niederschlag", `${current.precipitation ?? 0} mm`),
metric("Bewölkung", `${current.cloud_cover ?? 0} %`),
metric("Böen", convertWind(current.wind_gusts_10m ?? 0)),
].join("");
renderDaily(dash.forecast.data.daily);
renderHourly(dash.forecast.data.hourly);
renderHourStrip(dash.forecast.data.hourly);
}
if (dash.observations?.data) {
const weather = dash.observations.data.weather;
const source = dash.observations.data.sources?.[0];
if (weather) {
el("obsTemp").textContent = convertTemp(weather.temperature);
el("obsMeta").textContent = `${source?.station_name || "DWD Station"} · ${weather.timestamp || ""}`;
}
}
if (dash.radar) {
el("radarCount").textContent = `${dash.radar.count} online`;
el("radarMeta").textContent = `Produkt ${dash.radar.product?.toUpperCase() || "RV"} · DWD OpenData`;
}
if (dash.warnings) {
applyWarningsData(dash.warnings);
}
}
function applyWarningsData(data) {
const localOnly = el("warningsLocalOnly")?.checked ?? true;
const warnings = data.warnings || [];
el("warnCount").textContent = String(data.count_local ?? warnings.length);
el("warnMeta").textContent = localOnly
? `${warnings.length} Warnung(en) für deinen Standort`
: `${data.count_total} Warnungen bundesweit (Top ${warnings.length})`;
const warncellEl = el("warncellInfo");
if (localOnly && data.warncells?.length) {
warncellEl.classList.remove("hidden");
warncellEl.innerHTML = `Zugeordnete Warngebiete: ${data.warncells.map((a) => `<strong>${a.name}</strong> (${a.warncell_id})`).join(" · ")}`;
} else {
warncellEl.classList.add("hidden");
warncellEl.innerHTML = "";
}
el("warningsList").innerHTML = warnings.length ? warnings.map((w) => `
<article class="warning-card ${warnLevelClass(w.level)}">
<div class="warning-head">
<strong>${w.headline || w.event}</strong>
<span class="warn-badge">Level ${w.level} · ${w.level_label}</span>
</div>
<p class="muted">${w.region_name || ""} · ${w.state || ""}</p>
<p>${(w.description || "").replace(/\n/g, "<br>")}</p>
${w.instruction ? `<p class="instruction">${w.instruction}</p>` : ""}
<small class="muted">Gültig: ${w.start ? formatDateTime(w.start) : ""} ${w.end ? formatDateTime(w.end) : ""}</small>
</article>
`).join("") : `<p class="muted">${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}</p>`;
}
function renderSourceStatus(sources) {
const bad = sources.filter((s) => s.status === "error").length;
el("sourceStatusSummary").textContent = bad ? `${bad} Quelle(n) mit Problemen` : "Alle Quellen erreichbar";
el("sourceStatusGrid").innerHTML = sources.map((s) => `
<div class="source-status ${s.status}">
<strong>${s.name}</strong>
<span>${s.status}${s.latency_ms ? ` · ${s.latency_ms} ms` : ""}</span>
${s.error ? `<small>${s.error}</small>` : ""}
</div>
`).join("");
}
async function loadForecast() {
const loc = getLocation();
const data = await api(`/api/forecast?lat=${loc.lat}&lon=${loc.lon}`);
latestForecast = data;
const current = data.data.current;
el("currentTemp").textContent = convertTemp(current.temperature_2m);
el("currentMeta").textContent = `${weatherText(current.weather_code)} · Wind ${convertWind(current.wind_speed_10m)} · ${loc.place}`;
el("forecastSource").textContent = data.cached ? `${data.source} (Cache)` : data.source;
el("dailySource").textContent = data.cached ? `${data.source} (Cache)` : data.source;
el("currentDetails").innerHTML = [
metric("Gefühlt", convertTemp(current.apparent_temperature)),
metric("Luftfeuchte", `${current.relative_humidity_2m} %`),
metric("Luftdruck", `${Math.round(current.pressure_msl)} hPa`),
metric("Niederschlag", `${current.precipitation ?? 0} mm`),
metric("Bewölkung", `${current.cloud_cover ?? 0} %`),
metric("Böen", convertWind(current.wind_gusts_10m ?? 0)),
].join("");
renderDaily(data.data.daily);
renderHourly(data.data.hourly);
renderHourStrip(data.data.hourly);
}
function renderDaily(daily) {
el("dailyForecast").innerHTML = daily.time.map((date, idx) => {
const d = new Date(date + "T12:00:00");
const day = d.toLocaleDateString("de-DE", { weekday: "short", day: "2-digit", month: "2-digit" });
const sunrise = daily.sunrise?.[idx] ? new Date(daily.sunrise[idx]).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "";
const sunset = daily.sunset?.[idx] ? new Date(daily.sunset[idx]).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "";
return `<div class="day">
<strong>${day}</strong>
<span class="muted">${weatherText(daily.weather_code[idx])}</span>
<p class="temp">${convertTemp(daily.temperature_2m_max[idx])} / ${convertTemp(daily.temperature_2m_min[idx])}</p>
<span class="muted">Regen: ${daily.precipitation_sum[idx]} mm · ${daily.precipitation_probability_max[idx] ?? 0}%</span>
<span class="muted block">Wind: ${convertWind(daily.wind_speed_10m_max[idx] ?? 0)} · Böen ${convertWind(daily.wind_gusts_10m_max[idx] ?? 0)}</span>
<span class="muted block">Sonne: ${sunrise} ${sunset}</span>
</div>`;
}).join("");
}
function renderHourly(hourly) {
el("hourlyTable").innerHTML = hourly.time.slice(0, 48).map((time, idx) => `
<tr>
<td>${formatDateTime(time)}</td>
<td>${weatherText(hourly.weather_code[idx])}</td>
<td>${convertTemp(hourly.temperature_2m[idx])}</td>
<td>${hourly.precipitation_probability[idx] ?? 0}% · ${hourly.precipitation[idx] ?? 0} mm</td>
<td>${hourly.cloud_cover[idx] ?? 0}%</td>
<td>${convertWind(hourly.wind_speed_10m[idx] ?? 0)}</td>
<td>${convertWind(hourly.wind_gusts_10m[idx] ?? 0)}</td>
</tr>
`).join("");
}
function renderHourStrip(hourly) {
el("hourStrip").innerHTML = hourly.time.slice(0, 12).map((time, idx) => {
const label = new Date(time).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
const rain = hourly.precipitation_probability[idx] ?? 0;
return `<div class="hour-card">
<strong>${label}</strong>
<span>${convertTemp(hourly.temperature_2m[idx])}</span>
<small>${weatherText(hourly.weather_code[idx])}</small>
<div class="bar"><i style="width:${Math.min(rain, 100)}%"></i></div>
<small>Regen ${rain}% · Wind ${convertWind(hourly.wind_speed_10m[idx] ?? 0)}</small>
</div>`;
}).join("");
}
async function loadObservations() {
const loc = getLocation();
try {
const data = await api(`/api/observations?lat=${loc.lat}&lon=${loc.lon}`);
const weather = data.data.weather;
const source = data.data.sources?.[0];
if (!weather) throw new Error("Keine Beobachtung gefunden");
el("obsTemp").textContent = convertTemp(weather.temperature);
el("obsMeta").textContent = `${source?.station_name || "DWD Station"} · ${weather.timestamp || ""}`;
} catch (err) {
el("obsTemp").textContent = "";
el("obsMeta").textContent = `Keine DWD-Beobachtung: ${err.message}`;
}
}
async function loadWarnings() {
const loc = getLocation();
const localOnly = el("warningsLocalOnly")?.checked ?? true;
try {
const data = await api(`/api/warnings?lat=${loc.lat}&lon=${loc.lon}&local_only=${localOnly}`);
const warnings = data.warnings || [];
el("warnCount").textContent = String(data.count_local ?? warnings.length);
el("warnMeta").textContent = localOnly
? `${warnings.length} Warnung(en) für deinen Standort`
: `${data.count_total} Warnungen bundesweit (Top ${warnings.length})`;
const warncellEl = el("warncellInfo");
if (localOnly && data.warncells?.length) {
warncellEl.classList.remove("hidden");
warncellEl.innerHTML = `Zugeordnete Warngebiete: ${data.warncells.map((a) => `<strong>${a.name}</strong> (${a.warncell_id})`).join(" · ")}`;
} else {
warncellEl.classList.add("hidden");
warncellEl.innerHTML = "";
}
el("warningsList").innerHTML = warnings.length ? warnings.map((w) => `
<article class="warning-card ${warnLevelClass(w.level)}">
<div class="warning-head">
<strong>${w.headline || w.event}</strong>
<span class="warn-badge">Level ${w.level} · ${w.level_label}</span>
</div>
<p class="muted">${w.region_name || ""} · ${w.state || ""}</p>
<p>${(w.description || "").replace(/\n/g, "<br>")}</p>
${w.instruction ? `<p class="instruction">${w.instruction}</p>` : ""}
<small class="muted">Gültig: ${w.start ? formatDateTime(w.start) : ""} ${w.end ? formatDateTime(w.end) : ""}</small>
</article>
`).join("") : `<p class="muted">${localOnly ? "Keine aktiven Warnungen für deinen Standort." : "Keine Warnungen verfügbar."}</p>`;
} catch (err) {
el("warnCount").textContent = "";
el("warnMeta").textContent = err.message;
el("warningsList").innerHTML = `<p class="muted">Warnungen nicht verfügbar: ${err.message}</p>`;
el("warncellInfo")?.classList.add("hidden");
}
}
async function loadRadarFiles() {
const latest = await api("/api/radar/latest?limit=12");
el("radarCount").textContent = `${latest.count} online`;
el("radarMeta").textContent = `Produkt ${latest.product.toUpperCase()} · DWD OpenData`;
const local = await api("/api/radar/files");
el("radarFiles").innerHTML = local.files.length ? local.files.slice().reverse().map((file) => `
<tr><td>${file.name}</td><td>${formatBytes(file.size_bytes)}</td>
<td>${new Date(file.modified_utc).toLocaleString("de-DE")}</td>
<td><a href="${file.download_url}">Download</a></td></tr>
`).join("") : `<tr><td colspan="4" class="muted">Noch keine lokalen Radar-Dateien.</td></tr>`;
try {
const rendered = await api("/api/radar/rendered?limit=12");
renderedFrames = rendered.frames || [];
el("renderedCount").textContent = `${rendered.count} Frame(s)`;
el("renderedFrames").innerHTML = renderedFrames.length ? renderedFrames.map((f) => `
<figure class="rendered-frame">
<img src="${f.image_url}" alt="Radar ${f.filename}" loading="lazy" />
<figcaption>${f.filename}</figcaption>
</figure>
`).join("") : `<p class="muted">Noch keine gerenderten Frames. Renderer-Worker abwarten.</p>`;
} catch {
el("renderedCount").textContent = "";
el("renderedFrames").innerHTML = `<p class="muted">Renderer nicht verfügbar.</p>`;
}
}
async function syncRadar() {
const button = el("syncRadarBtn");
button.disabled = true;
button.textContent = "Sync läuft…";
try {
await api("/api/radar/sync?limit=6", { method: "POST" });
await loadRadarFiles();
} finally {
button.disabled = false;
button.textContent = "Radar jetzt syncen";
}
}
async function loadSources() {
try {
const data = await api("/api/sources");
el("sourceList").innerHTML = Object.entries(data).map(([key, value]) => `
<div class="source-item"><span>${key}</span><code>${String(value)}</code></div>
`).join("");
} catch (err) {
el("sourceList").innerHTML = `<p class="muted">${err.message}</p>`;
}
}
function setRadarTime(index) {
if (!radarTimes.length) return;
radarFrameIndex = Math.max(0, Math.min(index, radarTimes.length - 1));
const time = radarTimes[radarFrameIndex];
el("radarSlider").value = radarFrameIndex;
el("radarTimeLabel").textContent = formatDateTime(time);
el("radarFrameInfo").textContent = `Frame ${radarFrameIndex + 1} / ${radarTimes.length}`;
if (el("useLocalRadar").checked && renderedFrames.length) {
showLocalRadarFrame(radarFrameIndex);
} else if (radarLayer) {
radarLayer.setParams({ TIME: time, _cache: Date.now() });
}
}
function showLocalRadarFrame(index) {
if (!map) return;
const frame = renderedFrames[Math.min(index, renderedFrames.length - 1)];
if (!frame?.bounds) return;
const b = frame.bounds;
const bounds = [[b.south, b.west], [b.north, b.east]];
if (localRadarOverlay) map.removeLayer(localRadarOverlay);
localRadarOverlay = L.imageOverlay(frame.image_url + `?t=${Date.now()}`, bounds, { opacity: 0.75 });
localRadarOverlay.addTo(map);
}
async function loadRadarTimes() {
const layer = radarLayer?.wmsParams?.layers || "dwd:Niederschlagsradar";
const minutes = settings.radarMinutes || 120;
try {
const data = await api(`/api/radar/wms/times?layer=${encodeURIComponent(layer)}&minutes=${minutes}`);
radarTimes = data.times || [];
el("radarSlider").max = Math.max(0, radarTimes.length - 1);
if (radarTimes.length) setRadarTime(radarTimes.length - 1);
else el("radarTimeLabel").textContent = "Keine Zeitschritte";
} catch (err) {
el("radarTimeLabel").textContent = `Zeitleiste: ${err.message}`;
}
}
function toggleRadarPlay() {
if (radarPlaying) {
clearInterval(radarPlayTimer);
radarPlayTimer = null;
radarPlaying = false;
el("radarPlayBtn").textContent = "▶";
return;
}
if (radarTimes.length < 2) return;
radarPlaying = true;
el("radarPlayBtn").textContent = "⏸";
radarPlayTimer = setInterval(() => {
const next = radarFrameIndex + 1;
if (next >= radarTimes.length) setRadarTime(0);
else setRadarTime(next);
}, 800);
}
async function initMap() {
if (map || typeof L === "undefined") return;
const loc = getLocation();
map = L.map("map", { zoomControl: true }).setView([loc.lat, loc.lon], 8);
osmLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "&copy; OpenStreetMap-Mitwirkende",
}).addTo(map);
mapMarker = L.marker([loc.lat, loc.lon]).addTo(map).bindPopup(loc.place);
try {
const config = await api("/api/radar/wms");
const overlays = {};
wmsLayers = config.layers.map((layer) => {
const wms = L.tileLayer.wms(config.service_url, {
layers: layer.layer,
format: "image/png",
transparent: true,
version: "1.1.1",
opacity: layer.opacity ?? 0.7,
attribution: config.attribution,
});
overlays[layer.title] = wms;
if (layer.id === "niederschlagsradar") radarLayer = wms;
if (layer.id === "warnungen_gemeinden") warningLayer = wms;
if (layer.enabled) wms.addTo(map);
return { ...layer, instance: wms };
});
layerControl = L.control.layers({ "OpenStreetMap": osmLayer }, overlays, { collapsed: false }).addTo(map);
el("mapMeta").textContent = "DWD Radar + Warnungen aktiv";
await loadRadarTimes();
} catch (err) {
el("mapMeta").textContent = `Karte: ${err.message}`;
}
}
function updateMapLocation() {
if (!map || !mapMarker) return;
const loc = getLocation();
map.setView([loc.lat, loc.lon], Math.max(map.getZoom(), 8));
mapMarker.setLatLng([loc.lat, loc.lon]).bindPopup(loc.place);
}
function reloadRadarLayers() {
wmsLayers.forEach((layer) => {
if (map?.hasLayer(layer.instance)) layer.instance.setParams({ _cache: Date.now() });
});
loadRadarTimes();
}
function toggleWarningsOnMap() {
if (!warningLayer || !map) return;
if (el("showWarningsOnMap").checked) warningLayer.addTo(map);
else map.removeLayer(warningLayer);
}
async function searchPlaces(query) {
if (query.length < 2) {
el("searchResults").classList.add("hidden");
return;
}
try {
const data = await api(`/api/geocode/search?q=${encodeURIComponent(query)}`);
const results = data.results || [];
el("searchResults").innerHTML = results.length ? results.map((r, i) => `
<li role="option" data-index="${i}">
<strong>${r.place}</strong>
<span>${r.label}</span>
</li>
`).join("") : `<li class="muted">Keine Treffer</li>`;
el("searchResults").classList.remove("hidden");
el("searchResults").querySelectorAll("li[data-index]").forEach((item) => {
item.addEventListener("click", () => {
const r = results[Number(item.dataset.index)];
setLocation({ place: r.place, lat: r.lat, lon: r.lon });
el("searchResults").classList.add("hidden");
loadAll();
});
});
} catch {
el("searchResults").innerHTML = `<li class="muted">Suche fehlgeschlagen</li>`;
el("searchResults").classList.remove("hidden");
}
}
function renderFavorites() {
const favs = loadFavorites();
const panel = el("favoritesPanel");
if (!favs.length) { panel.classList.add("hidden"); return; }
panel.classList.remove("hidden");
el("favoritesList").innerHTML = favs.map((f, i) => `
<button class="chip" data-index="${i}">${f.place}</button>
`).join("");
el("favoritesList").querySelectorAll(".chip").forEach((btn) => {
btn.addEventListener("click", () => {
const f = favs[Number(btn.dataset.index)];
setLocation(f);
loadAll();
});
});
}
function addFavorite() {
const loc = getLocation();
const favs = loadFavorites().filter((f) => f.place !== loc.place || f.lat !== loc.lat);
favs.unshift(loc);
saveFavorites(favs.slice(0, 8));
renderFavorites();
}
function useGeolocation() {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(async (pos) => {
const lat = pos.coords.latitude;
const lon = pos.coords.longitude;
try {
const data = await api(`/api/geocode/reverse?lat=${lat}&lon=${lon}`);
setLocation({ place: data.result?.place || "GPS-Standort", lat, lon });
} catch {
setLocation({ place: "GPS-Standort", lat, lon });
}
loadAll();
});
}
function applyTheme() {
document.documentElement.dataset.theme = settings.theme;
el("themeToggle").textContent = settings.theme === "dark" ? "🌙" : "☀️";
}
function applySettingsToUI() {
el("unitTemp").value = settings.unitTemp;
el("unitWind").value = settings.unitWind;
el("radarMinutes").value = String(settings.radarMinutes);
applyTheme();
}
async function loadDefaultLocation() {
const stored = settings.defaultLocation;
if (stored) { setLocation(stored); return; }
try {
const data = await api("/api/location/default");
setLocation(data);
} catch { /* fallback values in HTML */ }
}
function activateView(name) {
document.querySelectorAll(".tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.view === name));
document.querySelectorAll(".view").forEach((view) => view.classList.toggle("active", view.dataset.view === name));
if (name === "radar") initMap().then(() => setTimeout(() => map?.invalidateSize(), 150));
if (name === "warnings") loadWarnings();
}
async function loadAll() {
await loadHealth();
await Promise.allSettled([loadForecast(), loadObservations(), loadRadarFiles(), loadSources(), loadWarnings()]);
updateMapLocation();
if (map) loadRadarTimes();
}
async function init() {
applySettingsToUI();
await loadDefaultLocation();
renderFavorites();
await loadAll();
setInterval(loadHealth, 30000);
}
document.querySelectorAll(".tab").forEach((tab) => tab.addEventListener("click", () => activateView(tab.dataset.view)));
el("loadBtn").addEventListener("click", loadAll);
el("syncRadarBtn").addEventListener("click", syncRadar);
el("centerMapBtn").addEventListener("click", updateMapLocation);
el("reloadRadarLayersBtn").addEventListener("click", reloadRadarLayers);
el("favBtn").addEventListener("click", addFavorite);
el("geoBtn").addEventListener("click", useGeolocation);
el("warningsLocalOnly")?.addEventListener("change", loadWarnings);
el("showWarningsOnMap")?.addEventListener("change", toggleWarningsOnMap);
el("useLocalRadar")?.addEventListener("change", () => setRadarTime(radarFrameIndex));
el("radarPlayBtn").addEventListener("click", toggleRadarPlay);
el("radarPrevBtn").addEventListener("click", () => setRadarTime(radarFrameIndex - 1));
el("radarNextBtn").addEventListener("click", () => setRadarTime(radarFrameIndex + 1));
el("radarFirstBtn").addEventListener("click", () => setRadarTime(0));
el("radarLastBtn").addEventListener("click", () => setRadarTime(radarTimes.length - 1));
el("radarSlider").addEventListener("input", (e) => setRadarTime(Number(e.target.value)));
el("placeSearch").addEventListener("input", (e) => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => searchPlaces(e.target.value.trim()), 300);
});
document.addEventListener("click", (e) => {
if (!e.target.closest(".search-wrap")) el("searchResults").classList.add("hidden");
});
el("themeToggle").addEventListener("click", () => {
settings.theme = settings.theme === "dark" ? "light" : "dark";
saveSettings();
applyTheme();
});
["unitTemp", "unitWind", "radarMinutes"].forEach((id) => {
el(id).addEventListener("change", (e) => {
const key = id === "radarMinutes" ? "radarMinutes" : id.replace("unit", "unit").replace("Temp", "Temp").replace("Wind", "Wind");
if (id === "unitTemp") settings.unitTemp = e.target.value;
if (id === "unitWind") settings.unitWind = e.target.value;
if (id === "radarMinutes") settings.radarMinutes = Number(e.target.value);
saveSettings();
if (latestForecast) { renderDaily(latestForecast.data.daily); renderHourly(latestForecast.data.hourly); renderHourStrip(latestForecast.data.hourly); loadForecast(); }
if (id === "radarMinutes" && map) loadRadarTimes();
});
});
el("saveDefaultLocationBtn").addEventListener("click", () => {
settings.defaultLocation = getLocation();
saveSettings();
el("saveDefaultLocationBtn").textContent = "Gespeichert ✓";
setTimeout(() => { el("saveDefaultLocationBtn").textContent = "Aktuellen Ort als Standard speichern"; }, 2000);
});
init();

29
frontend/datenschutz.html Normal file
View File

@@ -0,0 +1,29 @@
<!doctype html>
<html lang="de" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Datenschutz HexaWetter</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="legal-page">
<p><a href="index.html">← Zurück zu HexaWetter</a></p>
<h1>Datenschutzerklärung</h1>
<h2>1. Verantwortlicher</h2>
<p>Verantwortlich ist der Betreiber dieser selfhosted HexaWetter-Instanz (siehe Impressum).</p>
<h2>2. Verarbeitete Daten</h2>
<ul>
<li>Standortkoordinaten und Suchbegriffe werden an die eigene API und ggf. externe Dienste (Nominatim, DWD, Forecast-APIs) übermittelt.</li>
<li>Einstellungen und Favoriten werden lokal im Browser (localStorage) gespeichert.</li>
<li>Server-Logs können IP-Adresse, Zeitstempel und angefragte URLs enthalten.</li>
</ul>
<h2>3. Externe Dienste</h2>
<p>Je nach Konfiguration werden Daten an Open-Meteo, Bright Sky, DWD, OpenStreetMap/Nominatim und DWD WMS übertragen. Bitte die Datenschutzhinweise der jeweiligen Anbieter beachten.</p>
<h2>4. Speicherdauer</h2>
<p>API-Caches (PostgreSQL/Redis) und Radar-Rohdateien werden nach konfigurierter Retention gelöscht.</p>
<h2>5. Ihre Rechte</h2>
<p>Sie haben Rechte auf Auskunft, Berichtigung und Löschung personenbezogener Daten beim Verantwortlichen.</p>
</main>
</body>
</html>

26
frontend/impressum.html Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="de" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Impressum HexaWetter</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="legal-page">
<p><a href="index.html">← Zurück zu HexaWetter</a></p>
<h1>Impressum</h1>
<p>Angaben gemäß § 5 TMG (Muster bitte für den produktiven Betrieb anpassen):</p>
<p><strong>HexaHost / HexaWetter</strong><br />
Musterstraße 1<br />
94051 Hauzenberg<br />
Deutschland</p>
<p><strong>Kontakt:</strong> wetter@example.com</p>
<p><strong>Verantwortlich für den Inhalt:</strong> Betreiber dieser selfhosted Instanz</p>
<h2>Haftungsausschluss Wetterdaten</h2>
<p>HexaWetter aggregiert Daten aus öffentlichen Quellen (DWD, Open-Meteo-kompatible APIs, OpenStreetMap). Es handelt sich nicht um eine amtliche Wetterberatung des Deutschen Wetterdienstes.</p>
<h2>Quellen</h2>
<p>Deutscher Wetterdienst (DWD), Open Data · Open-Meteo-kompatible Forecast-API · Bright Sky-kompatible Stationsdaten · OpenStreetMap-Mitwirkende</p>
</main>
</body>
</html>

218
frontend/index.html Normal file
View File

@@ -0,0 +1,218 @@
<!doctype html>
<html lang="de" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="HexaWetter Selfhosted Wetterportal mit DWD Radar, Warnungen und Forecast" />
<title>HexaWetter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="site-header">
<div class="container header-inner">
<a class="brand-link" href="index.html">
<div class="brand-logo" aria-hidden="true">H</div>
<div>
<div class="brand-sub">HexaHost · Selfhosted Weather</div>
<div class="brand-title">HexaWetter</div>
</div>
</a>
<div class="header-actions">
<div class="status-pill" id="health">API: prüfe…</div>
<button id="themeToggle" class="btn btn-ghost icon-btn" title="Theme wechseln" aria-label="Theme wechseln">🌙</button>
<a href="admin.html" class="btn btn-secondary">Admin</a>
</div>
</div>
</header>
<main class="app-main">
<div class="container">
<section class="hero-copy">
<h1>Wetter für deinen Standort</h1>
<p>Forecast, DWD-Beobachtungen, interaktive Radar-Karte mit Zeitleiste und standortgenaue Warnungen gehostet auf deiner Infrastruktur.</p>
</section>
<section class="glass-card controls">
<div class="search-wrap">
<label for="placeSearch">Ortssuche / PLZ</label>
<input id="placeSearch" type="search" placeholder="z.B. Hauzenberg, 94051 Passau…" autocomplete="off" />
<ul id="searchResults" class="search-results hidden" role="listbox"></ul>
</div>
<div>
<label for="place">Ort / Label</label>
<input id="place" value="Hauzenberg" />
</div>
<div>
<label for="lat">Latitude</label>
<input id="lat" type="number" step="0.0001" value="48.6546" />
</div>
<div>
<label for="lon">Longitude</label>
<input id="lon" type="number" step="0.0001" value="13.6252" />
</div>
<div class="control-actions">
<button id="geoBtn" class="btn btn-secondary icon-btn" title="Browser-Standort">📍</button>
<button id="favBtn" class="btn btn-secondary icon-btn" title="Favorit"></button>
<button id="loadBtn" class="btn btn-primary">Aktualisieren</button>
</div>
</section>
<section class="glass-card favorites-panel hidden" id="favoritesPanel">
<div class="section-head">
<h2>Gespeicherte Orte</h2>
<span class="muted">Klicken zum Laden</span>
</div>
<div id="favoritesList" class="chip-row"></div>
</section>
<nav class="tabs" aria-label="Ansichten">
<button class="tab active" data-view="dashboard">Übersicht</button>
<button class="tab" data-view="radar">Radar-Karte</button>
<button class="tab" data-view="warnings">Warnungen</button>
<button class="tab" data-view="hourly">Stunden</button>
<button class="tab" data-view="daily">7 Tage</button>
<button class="tab" data-view="raw">Rohdaten</button>
<button class="tab" data-view="settings">Einstellungen</button>
</nav>
<section class="view active" data-view="dashboard">
<section class="grid">
<article class="glass-card card"><h2>Aktuelles Wetter</h2><p class="big" id="currentTemp"></p><p id="currentMeta" class="muted">Lade…</p></article>
<article class="glass-card card"><h2>DWD-Beobachtung</h2><p class="big" id="obsTemp"></p><p id="obsMeta" class="muted">Lade…</p></article>
<article class="glass-card card"><h2>Radar-Status</h2><p class="big" id="radarCount"></p><p id="radarMeta" class="muted">Lade…</p></article>
<article class="glass-card card"><h2>Warnungen</h2><p class="big" id="warnCount"></p><p id="warnMeta" class="muted">Lade…</p></article>
</section>
<section class="glass-card">
<div class="section-head"><h2>Details aktuell</h2><span class="muted" id="forecastSource"></span></div>
<div class="metrics" id="currentDetails"></div>
</section>
<section class="glass-card">
<div class="section-head"><h2>Kurzvorschau</h2><span class="muted">Nächste Stunden</span></div>
<div class="hour-strip" id="hourStrip"></div>
</section>
<section class="glass-card">
<div class="section-head"><h2>Datenquellen-Status</h2><span class="muted" id="sourceStatusSummary">Prüfe…</span></div>
<div class="source-status-grid" id="sourceStatusGrid"></div>
</section>
</section>
<section class="view" data-view="radar">
<section class="glass-card map-panel">
<div class="section-head">
<div><h2>Radar-Karte</h2><p class="muted no-margin">DWD WMS · OpenStreetMap · Zeitleiste</p></div>
<span class="muted" id="mapMeta">Lade…</span>
</div>
<div id="map"></div>
<div class="panel-inner" id="radarTimeline">
<div class="section-head"><strong>Radar-Zeitleiste</strong><span class="muted" id="radarTimeLabel"></span></div>
<div class="timeline-controls">
<button id="radarFirstBtn" class="btn btn-secondary icon-btn"></button>
<button id="radarPrevBtn" class="btn btn-secondary icon-btn"></button>
<button id="radarPlayBtn" class="btn btn-secondary icon-btn"></button>
<button id="radarNextBtn" class="btn btn-secondary icon-btn"></button>
<button id="radarLastBtn" class="btn btn-secondary icon-btn"></button>
<input id="radarSlider" type="range" min="0" max="0" value="0" />
</div>
<div class="timeline-meta">
<label class="inline-check"><input type="checkbox" id="useLocalRadar" /> Lokale Render-Frames</label>
<label class="inline-check"><input type="checkbox" id="showWarningsOnMap" checked /> Warnungen</label>
<span class="muted" id="radarFrameInfo"></span>
</div>
</div>
<div class="map-actions">
<button id="centerMapBtn" class="btn btn-secondary">Auf Standort zentrieren</button>
<button id="reloadRadarLayersBtn" class="btn btn-secondary">Layer neu laden</button>
</div>
</section>
</section>
<section class="view" data-view="warnings">
<section class="glass-card">
<div class="section-head">
<div>
<h2>Wetterwarnungen</h2>
<p class="muted no-margin">Standortgenau via DWD-Warncell-IDs (WFS)</p>
</div>
<label class="inline-check"><input type="checkbox" id="warningsLocalOnly" checked /> Nur Standort</label>
</div>
<div id="warncellInfo" class="warncell-info hidden"></div>
<div id="warningsList" class="warnings-list"></div>
</section>
</section>
<section class="view" data-view="hourly">
<section class="glass-card">
<div class="section-head"><h2>Stundenansicht</h2><span class="muted">48 Stunden</span></div>
<div class="table-wrap"><table><thead><tr><th>Zeit</th><th>Wetter</th><th>Temp.</th><th>Regen</th><th>Wolken</th><th>Wind</th><th>Böen</th></tr></thead><tbody id="hourlyTable"></tbody></table></div>
</section>
</section>
<section class="view" data-view="daily">
<section class="glass-card">
<div class="section-head"><h2>7-Tage Forecast</h2><span class="muted" id="dailySource"></span></div>
<div class="days" id="dailyForecast"></div>
</section>
</section>
<section class="view" data-view="raw">
<section class="glass-card">
<div class="section-head">
<div><h2>Radar-Rohdaten</h2><p class="muted no-margin">DWD OpenData · lokaler Renderer</p></div>
<button id="syncRadarBtn" class="btn btn-secondary">Sync starten</button>
</div>
<div class="table-wrap"><table><thead><tr><th>Datei</th><th>Größe</th><th>Geändert</th><th>Download</th></tr></thead><tbody id="radarFiles"></tbody></table></div>
</section>
<section class="glass-card">
<div class="section-head"><h2>Gerenderte Frames</h2><span class="muted" id="renderedCount"></span></div>
<div class="rendered-grid" id="renderedFrames"></div>
</section>
<section class="glass-card">
<div class="section-head"><h2>Datenquellen</h2></div>
<div class="source-list" id="sourceList"></div>
</section>
</section>
<section class="view" data-view="settings">
<section class="glass-card">
<h2>Einstellungen</h2>
<div class="settings-grid">
<label>Temperatureinheit<select id="unitTemp"><option value="celsius">°C</option><option value="fahrenheit">°F</option></select></label>
<label>Windeinheit<select id="unitWind"><option value="kmh">km/h</option><option value="ms">m/s</option><option value="kn">kn</option></select></label>
<label>Radar-Zeitleiste<select id="radarMinutes"><option value="60">60 Min.</option><option value="90">90 Min.</option><option value="120" selected>120 Min.</option></select></label>
<label>Standard-Standort<button id="saveDefaultLocationBtn" class="btn btn-secondary full-width">Aktuellen Ort speichern</button></label>
</div>
</section>
</section>
</div>
</main>
<footer class="site-footer">
<div class="container footer-grid">
<div>
<strong>HexaWetter</strong>
<p>Selfhosted Wetterportal von HexaHost. Keine amtliche Wetterberatung.</p>
<p class="muted">Daten: DWD Open Data · Open-Meteo · Bright Sky · OpenStreetMap</p>
</div>
<div>
<strong>Rechtliches</strong>
<ul>
<li><a href="impressum.html">Impressum</a></li>
<li><a href="datenschutz.html">Datenschutz</a></li>
</ul>
</div>
<div>
<strong>System</strong>
<ul>
<li><a href="admin.html">Admin-Bereich</a></li>
<li><a href="/api/health" target="_blank" rel="noopener">API Health</a></li>
</ul>
</div>
</div>
</footer>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="app.js"></script>
</body>
</html>

471
frontend/style.css Normal file
View File

@@ -0,0 +1,471 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Russo+One&display=swap");
:root {
--background-color: #0d0821;
--background-gradient: linear-gradient(135deg, #0d0821 0%, #1a1230 100%);
--primary-color: #ff51f9;
--accent-color-1: #a348ff;
--accent-color-2: #3978ff;
--highlight-color: #f093ff;
--text-primary: #ffffff;
--text-secondary: #cfc9dd;
--success-color: #32fba2;
--warning-color: #ffcc00;
--error-color: #ff4d6d;
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-border: rgba(255, 255, 255, 0.12);
--glass-shadow: 0 8px 32px rgba(255, 81, 249, 0.12);
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.25rem;
--space-1: 0.5rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
--space-5: 3rem;
--warn-info: #7ec8ff;
--warn-minor: #ffd166;
--warn-moderate: #ff9f43;
--warn-severe: #ff6b6b;
--warn-extreme: #c9184a;
}
html[data-theme="light"] {
--background-color: #f4f0ff;
--background-gradient: linear-gradient(135deg, #f4f0ff 0%, #ebe4ff 100%);
--text-primary: #1b1238;
--text-secondary: #5f5380;
--glass-bg: rgba(255, 255, 255, 0.82);
--glass-border: rgba(20, 10, 60, 0.1);
--glass-shadow: 0 8px 32px rgba(57, 120, 255, 0.08);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text-primary);
background: var(--background-gradient);
line-height: 1.6;
min-height: 100vh;
}
a { color: var(--primary-color); text-decoration: none; }
a:hover { color: var(--highlight-color); }
.container {
width: min(1200px, calc(100vw - 2rem));
margin: 0 auto;
padding: 0 var(--space-3);
}
.site-header {
position: sticky;
top: 0;
z-index: 200;
backdrop-filter: blur(16px);
background: rgba(13, 8, 33, 0.72);
border-bottom: 1px solid var(--glass-border);
}
html[data-theme="light"] .site-header { background: rgba(244, 240, 255, 0.88); }
.header-inner {
min-height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
flex-wrap: wrap;
padding: var(--space-2) 0;
}
.brand-link {
display: flex;
align-items: center;
gap: 14px;
color: var(--text-primary);
text-decoration: none;
}
.brand-logo {
width: 46px;
height: 46px;
border-radius: 14px;
display: grid;
place-items: center;
font-family: "Russo One", sans-serif;
font-size: 22px;
background: linear-gradient(135deg, var(--primary-color), var(--accent-color-2));
box-shadow: 0 10px 30px rgba(255, 81, 249, 0.28);
}
.brand-title {
font-family: "Russo One", sans-serif;
font-size: 1.35rem;
letter-spacing: 0.04em;
line-height: 1.1;
}
.brand-sub {
color: var(--text-secondary);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.14em;
font-weight: 700;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.status-pill {
padding: 0.55rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
font-size: 0.82rem;
color: var(--text-secondary);
}
.status-pill.bad { border-color: rgba(255, 77, 109, 0.45); color: #ffb4c2; }
.app-main { padding: var(--space-4) 0 var(--space-5); }
.hero-copy { margin-bottom: var(--space-3); }
.hero-copy h1 {
font-family: "Russo One", sans-serif;
font-size: clamp(2rem, 5vw, 3rem);
line-height: 1.1;
margin: 0.4rem 0 0.8rem;
}
.hero-copy p { color: var(--text-secondary); max-width: 760px; }
.glass-card,
.panel {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2xl);
backdrop-filter: blur(10px);
box-shadow: var(--glass-shadow);
padding: var(--space-3);
margin-bottom: var(--space-3);
}
.section-head {
display: flex;
justify-content: space-between;
gap: var(--space-2);
align-items: center;
margin-bottom: var(--space-2);
flex-wrap: wrap;
}
h2 { font-size: 1.25rem; margin-bottom: 0.75rem; }
h3 { font-size: 1rem; margin-bottom: 0.5rem; }
.muted, .text-secondary { color: var(--text-secondary); }
.no-margin { margin: 0; }
.block { display: block; margin-top: 0.5rem; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
border: 1px solid transparent;
border-radius: var(--radius-lg);
padding: 0.72rem 1rem;
font-weight: 700;
cursor: pointer;
font-size: 0.92rem;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.btn:hover { transform: translateY(-1px); }
.btn:disabled { opacity: 0.6; cursor: wait; transform: none; }
.btn-primary {
color: var(--text-primary);
background: linear-gradient(135deg, var(--primary-color), var(--accent-color-1));
}
.btn-secondary {
color: var(--text-primary);
background: var(--glass-bg);
border-color: var(--glass-border);
}
.btn-ghost {
color: var(--text-secondary);
background: transparent;
border-color: var(--glass-border);
}
.icon-btn { min-width: 44px; padding-inline: 0.7rem; }
.full-width { width: 100%; }
label {
display: block;
color: var(--text-secondary);
font-size: 0.82rem;
margin-bottom: 0.45rem;
font-weight: 600;
}
input, select, textarea {
width: 100%;
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
background: rgba(0, 0, 0, 0.22);
color: var(--text-primary);
padding: 0.72rem 0.85rem;
font-size: 0.95rem;
}
html[data-theme="light"] input,
html[data-theme="light"] select,
html[data-theme="light"] textarea { background: rgba(255,255,255,0.92); }
.controls {
display: grid;
grid-template-columns: 2fr 1.2fr 1fr 1fr auto;
gap: 12px;
align-items: end;
}
.search-wrap { position: relative; }
.search-results {
position: absolute;
z-index: 50;
left: 0; right: 0;
top: calc(100% + 6px);
list-style: none;
border-radius: var(--radius-lg);
background: rgba(20, 12, 40, 0.96);
border: 1px solid var(--glass-border);
max-height: 280px;
overflow: auto;
box-shadow: var(--glass-shadow);
}
html[data-theme="light"] .search-results { background: #fff; }
.search-results.hidden { display: none; }
.search-results li { padding: 0.7rem 0.85rem; cursor: pointer; }
.search-results li:hover { background: rgba(255,255,255,0.06); }
.search-results li strong { display: block; }
.search-results li span { color: var(--text-secondary); font-size: 0.78rem; }
.control-actions { display: flex; gap: 8px; align-items: end; }
.tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: var(--space-3);
}
.tab {
border-radius: var(--radius-lg);
padding: 0.62rem 0.95rem;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-primary);
cursor: pointer;
font-weight: 600;
}
.tab.active {
background: linear-gradient(135deg, rgba(255,81,249,0.95), rgba(57,120,255,0.95));
border-color: transparent;
}
.view { display: none; }
.view.active { display: block; }
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-2);
}
.card { min-height: 140px; }
.card h2 { font-size: 0.95rem; color: var(--text-secondary); font-weight: 600; }
.big {
font-size: clamp(1.8rem, 4vw, 2.4rem);
font-weight: 800;
line-height: 1.1;
margin: 0.35rem 0;
}
.metrics {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
}
.metric, .day, .hour-card, .source-item, .source-status, .warning-card, .admin-stat {
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
padding: 14px;
background: rgba(0,0,0,0.14);
}
.metric span { display: block; color: var(--text-secondary); font-size: 0.78rem; margin-bottom: 6px; }
.metric strong { font-size: 1.35rem; }
.hour-strip, .days {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.hour-card span { display: block; font-size: 1.35rem; font-weight: 800; margin: 0.4rem 0; }
.bar { height: 6px; border-radius: 999px; overflow: hidden; background: rgba(255,255,255,0.12); margin: 0.7rem 0; }
.bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-color-2), var(--primary-color)); }
#map {
min-height: 520px;
height: min(68vh, 720px);
border-radius: var(--radius-xl);
border: 1px solid var(--glass-border);
overflow: hidden;
}
.map-actions, .timeline-meta { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-top: 12px; }
.timeline-controls {
display: grid;
grid-template-columns: repeat(5, auto) 1fr;
gap: 8px;
align-items: center;
}
.panel-inner {
margin-top: 12px;
padding: 14px;
border-radius: var(--radius-xl);
border: 1px solid var(--glass-border);
background: rgba(0,0,0,0.12);
}
.inline-check { display: inline-flex; gap: 8px; align-items: center; font-size: 0.85rem; color: var(--text-secondary); }
.inline-check input { width: auto; }
.warnings-list { display: grid; gap: 14px; }
.warning-card { border-left: 4px solid var(--warn-info); }
.warning-card.warn-minor { border-left-color: var(--warn-minor); }
.warning-card.warn-moderate { border-left-color: var(--warn-moderate); }
.warning-card.warn-severe { border-left-color: var(--warn-severe); }
.warning-card.warn-extreme { border-left-color: var(--warn-extreme); }
.warning-head { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.warn-badge {
font-size: 0.75rem;
padding: 0.25rem 0.65rem;
border-radius: 999px;
background: rgba(255,255,255,0.08);
}
.instruction {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--glass-border);
color: var(--text-secondary);
font-size: 0.9rem;
}
.warncell-info {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: var(--radius-lg);
border: 1px solid var(--glass-border);
background: rgba(57,120,255,0.08);
font-size: 0.85rem;
}
.source-status-grid, .admin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
}
.source-status.ok { border-color: rgba(50, 251, 162, 0.35); }
.source-status.error { border-color: rgba(255, 77, 109, 0.4); }
.source-status strong { display: block; text-transform: capitalize; }
.chip-row { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
border-radius: 999px;
padding: 0.45rem 0.85rem;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
color: var(--text-primary);
cursor: pointer;
}
.favorites-panel.hidden { display: none; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 720px; }
th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--glass-border); }
th { color: var(--text-secondary); font-size: 0.8rem; }
.settings-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
.rendered-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.rendered-frame { border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
.rendered-frame img { width: 100%; display: block; aspect-ratio: 4/3; object-fit: cover; }
.rendered-frame figcaption { padding: 8px 10px; font-size: 0.78rem; color: var(--text-secondary); }
.site-footer {
border-top: 1px solid var(--glass-border);
background: rgba(0,0,0,0.22);
padding: var(--space-4) 0;
color: var(--text-secondary);
font-size: 0.86rem;
}
.footer-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: var(--space-3);
}
.footer-grid ul { list-style: none; }
.footer-grid li { margin-bottom: 0.35rem; }
.footer-grid a { color: var(--text-secondary); }
.footer-grid a:hover { color: var(--primary-color); }
.leaflet-control-layers, .leaflet-popup-content-wrapper { color: #1b1238; }
/* Admin */
.admin-page { min-height: 100vh; }
.admin-login-wrap {
min-height: calc(100vh - 80px);
display: grid;
place-items: center;
padding: var(--space-4) 0;
}
.admin-login-card { width: min(420px, 100%); }
.admin-login-card h1 {
font-family: "Russo One", sans-serif;
font-size: 1.8rem;
margin-bottom: 0.5rem;
}
.admin-error {
color: var(--error-color);
font-size: 0.85rem;
margin-top: 0.5rem;
}
.admin-hidden { display: none !important; }
.hidden { display: none !important; }
.admin-toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: var(--space-3);
}
.admin-stat strong { display: block; font-size: 1.2rem; }
.admin-stat span { color: var(--text-secondary); font-size: 0.82rem; }
.config-block {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.78rem;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-secondary);
}
.legal-page { max-width: 760px; margin: 40px auto; padding: 0 20px; }
.legal-page h1 { font-family: "Russo One", sans-serif; }
@media (max-width: 1000px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.controls { grid-template-columns: 1fr 1fr; }
.search-wrap, .control-actions { grid-column: 1 / -1; }
.footer-grid { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.grid, .metrics, .controls { grid-template-columns: 1fr; }
#map { min-height: 380px; height: 52vh; }
.timeline-controls { grid-template-columns: repeat(5, auto); }
.timeline-controls input[type=range] { grid-column: 1 / -1; }
}