new file: .dockerignore

new file:   Dockerfile
	new file:   __pycache__/app.cpython-310.pyc
	new file:   app.py
	new file:   requirements.txt
	new file:   static/css/style.css
	new file:   templates/base.html
	new file:   templates/index.html
	new file:   templates/weather.html
This commit is contained in:
SimolZimol
2026-04-21 09:01:53 +02:00
commit 2a9882c0aa
9 changed files with 1194 additions and 0 deletions

63
templates/base.html Normal file
View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{% block title %}Wetter{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;900&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}?v=2"/>
{% block head %}{% endblock %}
</head>
<body>
<nav class="nav">
<a href="/" class="nav-logo">DWD<span>Wetter</span></a>
<form class="nav-search" action="/wetter" method="get" autocomplete="off">
<div class="nav-search-wrap">
<svg class="nav-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="text" name="ort" id="nav-ort" placeholder="Ort suchen …" value="{{ request.args.get('ort','') }}"/>
<ul class="ac-list" id="nav-ac"></ul>
</div>
</form>
</nav>
<main>{% block content %}{% endblock %}</main>
<footer class="footer">
Daten: <a href="https://opendata.dwd.de" target="_blank">Deutscher Wetterdienst Open Data</a>
</footer>
<script>
function setupAC(input, list) {
if (!input || !list) return;
let t;
input.addEventListener("input", () => {
clearTimeout(t);
const q = input.value.trim();
if (q.length < 2) { list.hidden = true; list.innerHTML = ""; return; }
t = setTimeout(async () => {
const r = await fetch(`/api/suggest?q=${encodeURIComponent(q)}`);
const data = await r.json();
list.innerHTML = "";
if (!data.length) { list.hidden = true; return; }
data.forEach(item => {
const li = document.createElement("li");
li.textContent = item.name;
li.addEventListener("click", () => {
input.value = item.name;
list.hidden = true;
input.closest("form").submit();
});
list.appendChild(li);
});
list.hidden = false;
}, 220);
});
document.addEventListener("click", e => { if (!list.contains(e.target) && e.target !== input) list.hidden = true; });
}
setupAC(document.getElementById("nav-ort"), document.getElementById("nav-ac"));
window.setupAC = setupAC;
</script>
{% block scripts %}{% endblock %}
</body>
</html>

48
templates/index.html Normal file
View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}DWD Wetter{% endblock %}
{% block content %}
<div class="home-wrap">
<div class="home-bg-text" aria-hidden="true">WETTER</div>
<div class="home-center">
<p class="home-eyebrow">Deutscher Wetterdienst · Open Data</p>
<h1 class="home-title">Wo willst du<br>das Wetter wissen?</h1>
<form class="home-form" action="/wetter" method="get" autocomplete="off">
<div class="home-input-wrap">
<svg class="home-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="text" name="ort" id="home-ort" placeholder="Stadt, Gemeinde oder PLZ …" required autofocus/>
<ul class="ac-list" id="home-ac"></ul>
</div>
<button type="submit" class="home-btn">
Abrufen
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</form>
{% if error %}<p class="home-error">{{ error }}</p>{% endif %}
<div class="home-chips">
{% for city in ["Berlin","Hamburg","München","Köln","Frankfurt","Dresden","Düsseldorf","Stuttgart"] %}
<a href="/wetter?ort={{ city }}" class="chip">{{ city }}</a>
{% endfor %}
</div>
</div>
<div class="home-deco" aria-hidden="true">
<div class="deco-sun"></div>
<div class="deco-cloud c1"></div>
<div class="deco-cloud c2"></div>
<div class="deco-rain">
{% for i in range(18) %}<span></span>{% endfor %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>setupAC(document.getElementById("home-ort"), document.getElementById("home-ac"));</script>
{% endblock %}

265
templates/weather.html Normal file
View File

@@ -0,0 +1,265 @@
{% extends "base.html" %}
{% block title %}{{ display_name.split(',')[0] }} DWD Wetter{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
{% endblock %}
{% block content %}
{# ── Wetterklasse für den Hero-Gradient ─────────────────────────── #}
{% if current.icon == "🌧️" %}{% set wclass = "w-rain" %}
{% elif current.icon == "❄️" %}{% set wclass = "w-snow" %}
{% elif current.icon == "☁️" %}{% set wclass = "w-cloudy" %}
{% elif current.icon == "⛅" %}{% set wclass = "w-partcloud" %}
{% else %}{% set wclass = "w-clear" %}{% endif %}
<!-- HERO -->
<header class="hero {{ wclass }}">
<div class="hero-inner">
<div class="hero-meta">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ display_name.split(',')[0] }}
<span class="hero-meta-sub">{{ display_name.split(',')[1:3]|join(',') if ',' in display_name else '' }}</span>
</div>
<div class="hero-main">
<div class="hero-temp">{{ current.temp_c if current.temp_c is not none else "" }}°</div>
<div class="hero-desc">
<div class="hero-icon-big">{{ current.icon or "☁️" }}</div>
<div class="hero-stats-mini">
{% if current.wind_kmh is not none %}
<span>{{ current.wind_kmh }} km/h {{ wind_dir_name(current.wind_dir) }}</span>
{% endif %}
{% if current.cloud_pct is not none %}
<span>{{ current.cloud_pct }}% Bedeckung</span>
{% endif %}
{% if current.pressure_hpa is not none %}
<span>{{ current.pressure_hpa }} hPa</span>
{% endif %}
</div>
</div>
</div>
<div class="hero-metrics">
{% set items = [
("Gefühlt wie", (current.temp_c|string + " °C") if current.temp_c is not none else ""),
("Böen", (current.gust_kmh|string + " km/h") if current.gust_kmh is not none else ""),
("Niederschlag", (current.precip_mm|string + " mm") if current.precip_mm is not none else "0 mm"),
("Sonne", (current.sun_min|string + " min/h") if current.sun_min is not none else ""),
] %}
{% for label, val in items %}
<div class="hero-metric">
<span class="hm-val">{{ val }}</span>
<span class="hm-label">{{ label }}</span>
</div>
{% endfor %}
</div>
</div>
<div class="hero-station">
📡 Station <strong>{{ station_name }}</strong> ({{ station_dist }} km) · ID {{ station_id }}
</div>
</header>
<!-- STÜNDLICH -->
<section class="section">
<h2 class="section-title">Stundenweise</h2>
<div class="hourly-strip-wrap">
<div class="hourly-strip">
{% for h in forecast %}
<div class="hcard {% if loop.first %}hcard--now{% endif %}">
<div class="hcard-time">
{% if h.datetime is string %}{{ h.datetime[11:16] }}
{% else %}{{ h.datetime.strftime('%H:%M') }}{% endif %}
</div>
<div class="hcard-date">
{% if h.datetime is string %}{{ h.datetime[8:10] }}.{{ h.datetime[5:7] }}.
{% else %}{{ h.datetime.strftime('%d.%m.') }}{% endif %}
</div>
<div class="hcard-icon">{{ h.icon }}</div>
<div class="hcard-temp">
{% if h.temp_c is not none %}{{ h.temp_c }}°{% else %}{% endif %}
</div>
{% if h.precip_mm and h.precip_mm > 0 %}
<div class="hcard-precip">{{ h.precip_mm }} mm</div>
{% elif h.rain_prob is not none and h.rain_prob > 0 %}
<div class="hcard-precip hcard-precip--prob">{{ h.rain_prob }}%</div>
{% else %}
<div class="hcard-precip hcard-precip--none"></div>
{% endif %}
{% if h.wind_kmh is not none %}
<div class="hcard-wind">{{ h.wind_kmh }}<small>km/h</small></div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</section>
<!-- CHART -->
<section class="section">
<div class="chart-head">
<h2 class="section-title">Temperatur &amp; Niederschlag</h2>
<span class="chart-range">48 Stunden</span>
</div>
<div class="chart-box">
<canvas id="wxChart"></canvas>
</div>
</section>
<!-- TAGESÜBERSICHT -->
<section class="section">
<h2 class="section-title">Tagesübersicht</h2>
<div class="daily-list">
{# calc global min/max for bar scaling do it in a loop to stay Jinja2-safe #}
{% set ns = namespace(g_min=99, g_max=-99) %}
{% for d in daily %}
{% if d.temp_min is not none and d.temp_min < ns.g_min %}{% set ns.g_min = d.temp_min %}{% endif %}
{% if d.temp_max is not none and d.temp_max > ns.g_max %}{% set ns.g_max = d.temp_max %}{% endif %}
{% endfor %}
{% set g_min = ns.g_min %}
{% set g_max = ns.g_max %}
{% set g_range = (g_max - g_min) if (g_max - g_min) > 0 else 1 %}
{% for d in daily %}
<div class="drow">
<div class="drow-left">
<span class="drow-icon">{{ d.icon }}</span>
<div class="drow-date-wrap">
<span class="drow-dow">
{% if d.date is string %}
{% set y = d.date[:4]|int %}{% set mo = d.date[5:7]|int %}{% set dy = d.date[8:10]|int %}
{% set days = ["Mo","Di","Mi","Do","Fr","Sa","So"] %}
{{ dy }}.{{ d.date[5:7] }}.
{% else %}
{{ d.date.strftime('%a') }}
{% endif %}
</span>
</div>
</div>
<div class="drow-bar-wrap">
{% if d.temp_min is not none and d.temp_max is not none %}
{% set left_pct = ((d.temp_min - g_min) / g_range * 100)|round(1) %}
{% set width_pct = ((d.temp_max - d.temp_min) / g_range * 100)|round(1) %}
<div class="drow-bar-track">
<div class="drow-bar" style="left:{{ left_pct }}%; width:{{ [width_pct, 4]|max }}%"></div>
</div>
{% endif %}
</div>
<div class="drow-right">
<span class="drow-min">{{ d.temp_min }}°</span>
<span class="drow-max">{{ d.temp_max }}°</span>
{% if d.precip > 0 %}
<span class="drow-precip">🌧 {{ d.precip }}mm</span>
{% elif d.rain_prob is not none and d.rain_prob > 0 %}
<span class="drow-precip">💧 {{ d.rain_prob }}%</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
<p class="data-note">
Alle Daten: <a href="https://opendata.dwd.de" target="_blank">Deutscher Wetterdienst Open Data (MOSMIX)</a>
</p>
{% endblock %}
{% block scripts %}
<script>
(function() {
const labels = {{ chart_labels | tojson }};
const temps = {{ chart_temps | tojson }};
const precip = {{ chart_precip | tojson }};
// Nur jeden 3. Label anzeigen, Rest leer lassen
const sparseLabels = labels.map((l, i) => i % 3 === 0 ? l : "");
const ctx = document.getElementById("wxChart").getContext("2d");
// Gradient für Temperatur-Linie
const grad = ctx.createLinearGradient(0, 0, 0, 300);
grad.addColorStop(0, "rgba(255,140,50,0.35)");
grad.addColorStop(1, "rgba(255,140,50,0)");
new Chart(ctx, {
data: {
labels,
datasets: [
{
type: "line",
label: "Temperatur (°C)",
data: temps,
borderColor: "#ff8c32",
backgroundColor: grad,
borderWidth: 2.5,
tension: 0.45,
fill: true,
yAxisID: "yT",
pointRadius: 0,
pointHoverRadius: 5,
pointHoverBackgroundColor: "#ff8c32",
},
{
type: "bar",
label: "Niederschlag (mm)",
data: precip,
backgroundColor: "rgba(80,180,255,0.55)",
borderColor: "rgba(80,180,255,0.9)",
borderWidth: 1,
borderRadius: 3,
yAxisID: "yR",
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: {
labels: { color: "#9aa8b8", font: { family: "Inter", size: 12 }, boxWidth: 14 }
},
tooltip: {
backgroundColor: "rgba(10,12,18,0.92)",
borderColor: "rgba(255,255,255,0.1)",
borderWidth: 1,
titleColor: "#fff",
bodyColor: "#9aa8b8",
padding: 12,
callbacks: {
title: items => labels[items[0].dataIndex],
}
}
},
scales: {
x: {
ticks: {
color: "#5a6a7a",
font: { family: "Inter", size: 11 },
maxRotation: 0,
callback: (_, i) => i % 6 === 0 ? labels[i] : "",
autoSkip: false,
},
grid: { color: "rgba(255,255,255,0.04)" }
},
yT: {
position: "left",
ticks: { color: "#ff8c32", font: { family: "Inter", size: 11 }, callback: v => v + "°" },
grid: { color: "rgba(255,255,255,0.06)" }
},
yR: {
position: "right",
min: 0,
ticks: { color: "#50b4ff", font: { family: "Inter", size: 11 }, callback: v => v + "mm" },
grid: { display: false }
}
}
}
});
})();
</script>
{% endblock %}