Files
Paperwall/web/templates/base.html
T
Honey c182fb2f19 Add Journey screen: next connections for a fixed daily commute
New screens/journey.py, ported and simplified from github.com/EiS94/db_info
(a Home Assistant integration). Deliberately uses only the regional EFA
(Elektronische Fahrplanauskunft) backends the reference project queries
(VRR prioritized for NRW routes, falling back to Bavaria/BW instances) and
drops its bahn.de-internal-API source entirely -- that's DB's own
infrastructure, exactly the kind of thing DB has been actively blocking
(see the Departures screen). EFA instances are run by regional transit
authorities, not DB, so they aren't part of that clampdown. Also dropped
the reference project's parallel async fetch-and-score-all-sources
machinery for simple sequential fallback, matching this project's fully
synchronous style.

Verified against live VRR data for a real NRW route (Münster -> Dortmund):
real trains, platforms, transfer counts, even a night bus connection.

Caught a real bug during that live test: EFA returns UTC timestamps (Z
suffix), and datetime.fromisoformat() parses that correctly into an
aware datetime, but strftime() on an aware datetime prints its own
stored hour/minute rather than converting to local time first --
everything displayed 2 hours behind (CEST is UTC+2), which looked like
"next" departures had already left. Fixed by converting with
astimezone() before formatting; re-verified live afterward that
departures now correctly show after the query time.

Settings: origin/destination resolved via Open-Meteo's free geocoding
(same pattern as Weather's city field), how many connections to show
(1/2/3/5), and its own configurable update interval.
2026-07-22 22:33:36 +02:00

204 lines
6.6 KiB
HTML

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Paperwall</title>
<!-- Inline SVG favicon, no static asset needed: a dark panel frame with a
light "screen" inset and the same dot used as the brand mark in the
nav ("● Paperwall") -- a little wall-mounted display, on brand. -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%23121212'/><rect x='6' y='6' width='20' height='20' rx='3' fill='%23f2f2f0'/><circle cx='10' cy='10' r='2.2' fill='%23121212'/></svg>">
<style>
/* Dark mode only, no light theme -- doesn't follow prefers-color-scheme. */
:root {
--bg: #121212;
--surface: #1c1c1c;
--border: #2f2f2f;
--text: #f2f2f0;
--text-muted: #9a9a97;
--accent: #f2f2f0;
--accent-contrast: #121212;
--success: #3fb26f;
--danger: #e4685a;
--radius: 12px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 6px 20px rgba(0, 0, 0, 0.35);
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding: 2.5rem 1.25rem 4rem;
line-height: 1.5;
}
.wrap { max-width: 640px; margin: 0 auto; }
.brand {
font-weight: 700;
font-size: 1.15rem;
letter-spacing: -0.01em;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.brand .dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--text); }
nav {
display: flex;
gap: 0.15rem;
margin-bottom: 2rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.25rem;
width: fit-content;
max-width: 100%;
overflow-x: auto;
}
nav a {
color: var(--text-muted);
text-decoration: none;
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
padding: 0.45rem 0.9rem;
border-radius: 999px;
transition: background 0.15s, color 0.15s;
}
nav a:hover { color: var(--text); }
nav a.active { background: var(--accent); color: var(--accent-contrast); }
h1 { font-size: 1.4rem; margin: 0 0 1.25rem; letter-spacing: -0.01em; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1.25rem;
box-shadow: var(--shadow);
}
.section-label {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.72rem;
font-weight: 600;
color: var(--text-muted);
margin: 0 0 0.85rem;
}
.field { margin-bottom: 1.1rem; }
.field:last-child { margin-bottom: 0; }
label { display: block; font-weight: 600; margin-bottom: 0.35rem; font-size: 0.85rem; }
.hint { font-size: 0.78rem; color: var(--text-muted); margin: 0.35rem 0 0; }
input[type=text], input[type=password], input[type=number], select, input[type=file] {
width: 100%;
padding: 0.6rem 0.7rem;
font-size: 0.95rem;
font-family: inherit;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
transition: border-color 0.15s;
}
input:focus { outline: none; border-color: var(--text); }
.checkbox {
display: flex;
align-items: center;
gap: 0.6rem;
font-weight: 400;
font-size: 0.9rem;
margin-bottom: 0.6rem;
cursor: pointer;
}
.checkbox:last-child { margin-bottom: 0; }
.checkbox input { width: 1.05rem; height: 1.05rem; accent-color: var(--text); cursor: pointer; }
button {
padding: 0.6rem 1.1rem;
border: 0;
border-radius: 8px;
background: var(--accent);
color: var(--accent-contrast);
font-size: 0.88rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: opacity 0.15s;
}
button:hover { opacity: 0.85; }
button.secondary { background: transparent; color: var(--text); border: 1px solid var(--border); }
.preview-wrap {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
display: flex;
justify-content: center;
}
.preview { max-width: 100%; border-radius: 4px; display: block; }
.status-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.55rem 1.25rem; font-size: 0.88rem; margin: 0; }
.status-grid dt { color: var(--text-muted); }
.status-grid dd { margin: 0; font-weight: 500; }
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: var(--bg);
border: 1px solid var(--border);
}
.badge.on { color: var(--success); }
.badge.off { color: var(--text-muted); }
.actions { display: flex; gap: 0.6rem; flex-wrap: wrap; }
form.inline { display: inline; }
.toast {
background: var(--success);
color: white;
padding: 0.65rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 1.25rem;
}
.toast.error { background: var(--danger); }
</style>
</head>
<body>
<div class="wrap">
<div class="brand"><span class="dot"></span> Paperwall</div>
<nav>
<a href="{{ url_for('index') }}" class="{{ 'active' if request.endpoint == 'index' }}">Status</a>
<a href="{{ url_for('settings_navidrome') }}" class="{{ 'active' if request.endpoint == 'settings_navidrome' }}">Navidrome</a>
<a href="{{ url_for('settings_weather') }}" class="{{ 'active' if request.endpoint == 'settings_weather' }}">Weather</a>
<a href="{{ url_for('settings_departures') }}" class="{{ 'active' if request.endpoint == 'settings_departures' }}">Departures</a>
<a href="{{ url_for('settings_journey') }}" class="{{ 'active' if request.endpoint == 'settings_journey' }}">Journey</a>
<a href="{{ url_for('settings_picture') }}" class="{{ 'active' if request.endpoint == 'settings_picture' }}">Picture</a>
<a href="{{ url_for('settings_rotation') }}" class="{{ 'active' if request.endpoint == 'settings_rotation' }}">Rotation</a>
</nav>
{% if request.args.get('saved') %}
<div class="toast">Saved.</div>
{% endif %}
{% block content %}{% endblock %}
</div>
</body>
</html>