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.
This commit is contained in:
commit
cc9e0786c2
189 files changed
+29792
No files matched your search
@@ -0,0 +1,273 @@
|
||||
{% 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>
|
||||
Reference in new issue
Block a user