Files
vogelalbum/web/templates/dex/base.html
T
christian cc9e0786c2 Vogelalbum 1.0.0
Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine
Sammlung, die sich von allein fuellt.

Zwei neuronale Netze: eines findet den Vogel im Bild, eines bestimmt die
Art. 148 heimische Arten, auf Deutschland eingeschraenkt und neu normiert —
ohne diese Einschraenkung wird aus einer Kohlmeise eine nordamerikanische
Schwesterart. Daraus entsteht ein Sammelalbum: nummerierte Seiten,
gefundene Arten in Farbe, fehlende als Schatten, dazu Erstfang, Punkte,
Stufen und 15 Abzeichen.

Laeuft vollstaendig auf eigener Hardware. Kein Cloud-Dienst, kein Konto,
keine Telemetrie; die Bilder verlassen das eigene Netz nicht.

Zwei getrennt deploybare Teile: die Sammlung als Django-Anwendung, die
Erkennung als eigener Dienst. Der Erkennungs-Dienst fragt selbst an und
braucht deshalb weder eine erreichbare Adresse noch einen offenen Port —
er laeuft hinter einem gewoehnlichen Router.

Dabei: Firmware fuer Raspberry Pi und ESP32-S3, Anbindung an Home
Assistant ueber MQTT, Oberflaeche auf Deutsch und Englisch, Einstellungen
je Kamera aus dem Browser, und ein Weg fuer Fotos von unterwegs.

AGPL-3.0.
2026-10-14 18:55:23 +00:00

274 lines
12 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% load static i18n dex_extras %}{% get_current_language as LANG %}<!doctype html>
<html lang="{{ LANG }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Vogelalbum{% endblock %}</title>
<meta name="description" content="{% trans "Vögel erkennen, sammeln und katalogisieren — auf dem eigenen Server, ohne Cloud." %}">
{% comment %}
Vor dem Stylesheet und ohne defer: sonst rendert die Seite kurz hell und
springt dann auf dunkel. Das Flackern sieht man, und es nervt.
{% endcomment %}
<script>
(function () {
try {
var g = localStorage.getItem("vogelalbum-theme");
var d = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (g === "dark" || (g === null && d)) {
document.documentElement.setAttribute("data-theme", "dark");
}
} catch (e) { /* privater Modus: dann eben Standard */ }
})();
</script>
<link rel="stylesheet" href="{% static_v 'css/vogelalbum.css' %}">
{% comment %}
⚠ Hier stand ein Emoji als Symbol im Reiter. Das sieht auf einem Mac gut
aus und ist anderswo ein leeres Kaestchen: Emoji kommen aus einer Schrift,
die nicht jedes System mitbringt — ein Linux-Browser ohne Emoji-Schrift
zeigte an der Stelle ein Tofu-Rechteck, und zwar genau dort, wo die Marke
steht. Eine Marke darf nicht davon abhaengen, was auf dem fremden Rechner
installiert ist.
{% endcomment %}
<link rel="icon" type="image/svg+xml" href="{% static_v 'img/marke.svg' %}">
{% comment %}
⚠ Fuer den Startbildschirm reicht das SVG NICHT: iOS nimmt dort weder SVG
noch Durchsichtigkeit. Ohne diese Zeile legt Safari einen verkleinerten
Bildschirmabzug der Seite als Symbol ab — das sieht aus wie ein Fehler.
Deshalb ein PNG mit Flaeche darunter und Luft am Rand, weil das System
die Ecken selbst zuschneidet.
{% endcomment %}
<link rel="apple-touch-icon" href="{% static 'img/symbol-180.png' %}">
<link rel="manifest" href="{% static 'manifest.webmanifest' %}">
<meta name="theme-color" content="#106943">
{% comment %}
Hier gehoert seitenbezogenes CSS hin, NICHT in den title-Block.
⚠ Der Titel steht in <title>…</title>, und dort behandelt der Browser
alles als Text. Ein <style> darin wird nie angewandt — es landet
stattdessen im Seitentitel. Die Abzeichen-Seite hatte ihre Regeln auf
diesem Weg eingebunden, und der Fortschrittsbalken war dadurch 0 Pixel
hoch: unsichtbar, ohne dass irgendwo ein Fehler erschien.
{% endcomment %}
{% block kopf %}{% endblock %}
</head>
<body>
<header class="top">
<div class="wrap top-inner">
<a href="{% url 'home' %}" class="brand">
<span class="scheibe"><img src="{% static_v 'img/marke.svg' %}" alt="" width="31" height="31"></span>
<span class="wort">Vogel<span>album</span></span></a>
{% if live_available %}
{% comment %}
Live steht bewusst nicht in der Rubriken-Navigation: es ist kein Bereich
der Sammlung, sondern ein Zustand — und es gibt es nur, solange der
Detector läuft. Als fünfter Punkt sprengte es auf dem Telefon die Zeile;
hier ist es sichtbarer und kostet die Navigation nichts.
{% endcomment %}
<a href="{% url 'live' %}" class="live-knopf {% if request.resolver_match.url_name == 'live' %}on{% endif %}">
<span class="punkt" aria-hidden="true"></span>{% trans "Live" %}
</a>
{% endif %}
<nav class="main">
<a href="{% url 'home' %}" {% if request.resolver_match.url_name == 'home' %}class="active"{% endif %}>{% trans "Übersicht" %}</a>
<a href="{% url 'dex' %}" {% if request.resolver_match.url_name == 'dex' %}class="active"{% endif %}>{% trans "Sammlung" %}</a>
<a href="{% url 'sightings' %}" {% if request.resolver_match.url_name == 'sightings' %}class="active"{% endif %}>{% trans "Sichtungen" %}</a>
<a href="{% url 'badges' %}" {% if request.resolver_match.url_name == 'badges' %}class="active"{% endif %}>{% trans "Abzeichen" %}</a>
</nav>
{% comment %}
Djangos set_language erwartet POST. Ein Formular statt eines Links ist
hier auch inhaltlich richtig: die Sprachwahl ändert Serverzustand.
{% endcomment %}
<form action="{% url 'set_language' %}" method="post" class="lang-switch">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" name="language" value="de"
{% if LANG == 'de' %}class="on" aria-current="true"{% endif %}>DE</button>
<button type="submit" name="language" value="en"
{% if LANG == 'en' %}class="on" aria-current="true"{% endif %}>EN</button>
</form>
<button class="theme-toggle" type="button" id="theme-toggle"
aria-label="{% trans "Zwischen hellem und dunklem Modus wechseln" %}">
{% comment %}
Gezeichnet statt Emoji, aus demselben Grund wie beim Reitersymbol:
☀️ und 🌙 sehen auf jedem System anders aus und fehlen auf manchen ganz.
currentColor laesst sie die Textfarbe erben, also auch im dunklen Modus.
{% endcomment %}
<svg class="icon-light" viewBox="0 0 24 24" aria-hidden="true" width="17" height="17">
<path d="M20.3 14.2A8.5 8.5 0 0 1 9.8 3.7a8.5 8.5 0 1 0 10.5 10.5z"
fill="currentColor"/>
</svg>
<svg class="icon-dark" viewBox="0 0 24 24" aria-hidden="true" width="17" height="17">
<circle cx="12" cy="12" r="4.4" fill="currentColor"/>
<g stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M12 2.2v2.4M12 19.4v2.4M2.2 12h2.4M19.4 12h2.4"/>
<path d="M5.1 5.1 6.8 6.8M17.2 17.2l1.7 1.7M18.9 5.1l-1.7 1.7M6.8 17.2l-1.7 1.7"/>
</g>
</svg>
</button>
{% comment %}
Kameras, Live-Bild und Arten stellt man im Django-Admin ein. Oben bei den
Bedienelementen versteht man ein Zahnrad ohne Beschriftung — im Footer
neben einem Fließtext nicht.
{% endcomment %}
<a href="{% url 'admin:index' %}" class="gear" title="{% trans 'Einstellungen' %}"
aria-label="{% trans 'Einstellungen' %}">
<svg viewBox="0 0 24 24" aria-hidden="true" width="17" height="17">
<path fill="currentColor" d="M12 15.4a3.4 3.4 0 1 1 0-6.8 3.4 3.4 0 0 1 0 6.8zm8.3-2.2.1-1.2-.1-1.2 2-1.5-1.9-3.4-2.4.9a7.9 7.9 0 0 0-2.1-1.2L15.5 3h-3.9l-.4 2.6c-.8.3-1.5.7-2.1 1.2l-2.4-.9L4.8 9.3l2 1.5-.1 1.2.1 1.2-2 1.5 1.9 3.4 2.4-.9c.6.5 1.3.9 2.1 1.2l.4 2.6h3.9l.4-2.6c.8-.3 1.5-.7 2.1-1.2l2.4.9 1.9-3.4z"/>
</svg></a>
</div>
</header>
<main class="wrap">
{% block progress %}
<div class="progress-bar">
<div class="level-badge">{{ progress.level }}</div>
<div style="flex:1 1 240px; min-width:200px">
<div class="rank">{{ progress.rank }}</div>
<div class="xp-track" style="margin:8px 0 6px">
<div class="xp-fill" style="width: {% widthratio progress.level_progress 1 100 %}%"></div>
</div>
<div class="sub">{% blocktrans with xp=progress.xp rest=progress.xp_to_next lvl=progress.level|add:1 %}{{ xp }} XP · noch {{ rest }} XP bis Level {{ lvl }}{% endblocktrans %}</div>
</div>
<div style="text-align:right">
<div class="rank">{{ progress.species_seen }} / {{ progress.species_total }}</div>
<div class="sub">{% trans "Arten gesammelt" %}</div>
</div>
</div>
{% endblock %}
{% block content %}{% endblock %}
</main>
{% comment %}
Der Fuß ist die einzige Stelle, an der diese Seite für sich selbst wirbt.
Wer hier unten ankommt, hat sich durch fremde Vogelfotos geklickt und
gerade verstanden, was das Ding tut — das ist der Moment für den Satz
"kannst du auch haben", nicht die Startseite. Deshalb steht hier ein
Angebot und kein Kleingedrucktes.
Nebenbei erfüllt der Link die AGPL: Wer eine Fassung über ein Netz
anbietet, muss den Quelltext zugänglich machen. Wohin er zeigt, steht in
settings.QUELLTEXT_URL und ist überschreibbar — wer etwas verändert,
verlinkt SEIN Repository.
{% endcomment %}
<footer class="bottom">
{% if quelltext_url %}
<div class="wrap werbung">
<div class="werbung-text">
<p class="werbung-haken">{% trans "Dieses Vogelalbum füllt sich von allein." %}</p>
<p class="werbung-satz">{% trans "Eine Kamera am Futterhaus, der Rest passiert auf deinem eigenen Server — ohne Cloud, ohne Konto, ohne Abo. Freie Software zum Nachbauen." %}</p>
</div>
<a href="{{ quelltext_url }}" class="werbung-knopf" rel="noopener">
{% trans "Projekt ansehen" %}
<svg viewBox="0 0 24 24" aria-hidden="true" width="16" height="16">
<path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"
stroke-linejoin="round" d="M5 12h13M13 6l6 6-6 6"/>
</svg>
</a>
</div>
{% endif %}
{% comment %}
Hier stand bis zum 10.10.2026 noch ein Satz: "Vogelalbum · Arterkennung
auf dem eigenen Server. Keine Cloud, keine fremden Dienste." Der sagte
dasselbe wie der Block darueber, nur leiser und ohne Angebot. Zwei
Fassungen derselben Aussage untereinander schwaechen beide.
{% endcomment %}
</footer>
<!-- Grossansicht fuer Sichtungsfotos.
🔑 Warum das hier im Grundgeruest steht und nicht auf jeder Seite:
Vorschaubilder gibt es auf der Startseite, in der Sichtungsliste und
auf der Artenseite. Dreimal dasselbe zu bauen heisst, es dreimal zu
pflegen. -->
<div id="grossansicht" hidden>
<img alt="">
<div class="bildunterschrift"></div>
</div>
<style>
#grossansicht {
position: fixed; inset: 0; z-index: 90;
background: rgba(0,0,0,.88);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 14px; padding: 20px; cursor: zoom-out;
}
#grossansicht[hidden] { display: none; }
#grossansicht img {
max-width: 100%; max-height: 82vh;
border-radius: 10px; object-fit: contain;
}
/* ⚠ Eigene Farbe noetig: Die Seitenschrift ist fast schwarz und waere
auf dem dunklen Grund unsichtbar. Derselbe Fehler wie bei den
Live-Karten am 24.09. */
#grossansicht .bildunterschrift {
color: #fff; font-size: 14px; text-align: center;
text-shadow: 0 1px 3px rgba(0,0,0,.8);
}
</style>
<script>
/*
* Klick auf ein Vorschaubild zeigt das grosse Foto.
*
* 🔑 Das grosse Bild liegt bei JEDER Sichtung auf der Platte -- es wurde
* bisher nur nie verlinkt. Aus der Rueckmeldung: "man kann sich aber
* nirgendwo die Fotos der einzelnen Besuche in voller groesse anschauen,
* das ist schade weil nur das kleine Vorschaubild verschwendet Potenzial".
*
* ⚠ Ueber Delegation am Dokument, nicht je Bild: Die Sichtungsliste kann
* nachgeladen werden, und dann haetten neue Zeilen keinen Zuhoerer.
*/
(function () {
var schirm = document.getElementById("grossansicht");
if (!schirm) return;
var bild = schirm.querySelector("img");
var text = schirm.querySelector(".bildunterschrift");
function zeigen(quelle, beschriftung) {
bild.src = quelle;
text.textContent = beschriftung || "";
schirm.hidden = false;
// Die Seite darf hinter der Ueberlagerung nicht mitscrollen.
document.body.style.overflow = "hidden";
}
function schliessen() {
schirm.hidden = true;
bild.src = "";
document.body.style.overflow = "";
}
document.addEventListener("click", function (e) {
var ziel = e.target.closest("[data-gross]");
if (ziel) {
e.preventDefault();
zeigen(ziel.dataset.gross, ziel.dataset.beschriftung);
return;
}
if (!schirm.hidden && schirm.contains(e.target)) schliessen();
});
// Escape schliesst -- und der Zurueck-Knopf am Handy soll nicht die
// ganze Seite verlassen, nur weil ein Bild offen ist.
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !schirm.hidden) schliessen();
});
})();
</script>
<script>
document.getElementById("theme-toggle").addEventListener("click", function () {
var root = document.documentElement;
var dark = root.getAttribute("data-theme") === "dark";
if (dark) { root.removeAttribute("data-theme"); }
else { root.setAttribute("data-theme", "dark"); }
try { localStorage.setItem("vogelalbum-theme", dark ? "light" : "dark"); } catch (e) {}
});
</script>
</body>
</html>