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:
christian committed 2026-10-15 14:30:02 +00:00
commit c11fe9ab04
189 files changed
+29806

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
{% extends "dex/base.html" %}
{% load i18n dex_extras %}
{% block title %}{% trans "Abzeichen" %} — Vogelalbum{% endblock %}
{% block kopf %}
<style>
/* ⚠ Eigene Klassen statt der xp-track vom Kopfbereich: Die ist dort
breit und hoch, hier soll der Balken in eine Abzeichenkarte passen
ohne sie auseinanderzuziehen. */
.fortschritt {
margin-top: 8px;
height: 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--text) 12%, transparent);
overflow: hidden;
}
.fortschritt-fuellung {
height: 100%;
border-radius: 999px;
background: var(--accent);
/* Auch bei 0 % ein Hauch sichtbar -- sonst sieht die Karte aus,
als fehlte der Balken. */
min-width: 3px;
}
.fortschritt-text {
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
/* Die Karte ist abgeblendet, solange das Abzeichen fehlt. Der Balken
soll trotzdem lesbar bleiben -- er ist ja der Grund hinzuschauen. */
.badge.locked .fortschritt-fuellung { opacity: .85; }
</style>
{% endblock %}
{% block content %}
<h2>{% trans "Abzeichen" %} <span class="count">{% blocktrans with n=progress.badges %}{{ n }} verdient{% endblocktrans %}</span></h2>
<div class="badges">
{% for row in rows %}
<div class="badge {% if not row.earned %}locked{% endif %}">
{% abzeichen_bild row.badge.code as pin %}
{% if pin %}
<img class="em" src="{{ pin }}" alt="" width="72" height="72" loading="lazy">
{% else %}
<div class="em">{{ row.badge.emoji }}</div>
{% endif %}
<div>
<div class="n">{{ row.badge.name_lokal }}</div>
<div class="d">{{ row.badge.description_lokal }}</div>
<div class="d" style="margin-top:6px; color:var(--accent-dim); font-weight:700">
{% if row.earned %}{{ row.at|date:"SHORT_DATE_FORMAT" }} · +{{ row.badge.xp }} XP{% else %}+{{ row.badge.xp }} XP{% endif %}
</div>
{% comment %}
🔑 Balken nur, wo es etwas zu zaehlen gibt. Bei "Ein Specht war da"
stuende er dauerhaft auf null und waere keine Information.
⚠️ {# … #} ist in Django EINZEILIG — mehrzeilig braucht es comment,
sonst steht der Text sichtbar auf der Seite.
{% endcomment %}
{% if row.ziel %}
<div class="fortschritt" title="{{ row.ist }} von {{ row.ziel }}">
<div class="fortschritt-fuellung" style="width: {{ row.anteil }}%"></div>
</div>
<div class="d fortschritt-text">{{ row.ist }} / {{ row.ziel }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
+273
View File
@@ -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>
+98
View File
@@ -0,0 +1,98 @@
{% extends "dex/base.html" %}
{% load i18n %}
{% block title %}{% trans "Sammlung" %} — Vogelalbum{% endblock %}
{% block content %}
<h2>{% trans "Die Sammlung" %}
<span class="count">
{% blocktrans with a=found_count b=total_count %}{{ a }} von {{ b }} Arten{% endblocktrans %}{% if habitat_label %} · {{ habitat_label }}{% endif %}
</span>
</h2>
{% comment %}
Lebensraum ist die Hauptachse und bleibt sichtbar. Seltenheit und Gruppe
sind Verfeinerungen und gehören in Auswahlfelder — 27 Typen als Pillen
waren auf dem Handy zwölf Zeilen, bevor der erste Vogel kam.
{% endcomment %}
<div class="filterleiste">
{% if habitate %}
<div class="filtergruppe">
<span class="filterlabel">{% trans "Lebensraum" %}</span>
<div class="pills">
{% for h in habitate %}
<a href="?lebensraum={{ h.code }}" {% if habitat == h.code %}class="on"{% endif %}>{{ h.label }} <b>{{ h.n }}</b></a>
{% endfor %}
<a href="?lebensraum=alle" {% if not habitat %}class="on"{% endif %}>{% trans "Alle" %} <b>{{ gesamt_alle }}</b></a>
</div>
</div>
{% endif %}
<form class="filtergruppe verfeinern" method="get" action="{% url 'dex' %}">
<input type="hidden" name="lebensraum" value="{{ habitat|default:'alle' }}">
<label>
<span class="filterlabel">{% trans "Stufe" %}</span>
<select name="seltenheit" onchange="this.form.submit()">
<option value="">{% trans "Alle Stufen" %}</option>
{% for value, label in rarities %}
<option value="{{ value }}" {% if rarity_filter == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
<span class="filterlabel">{% trans "Gruppe" %}</span>
<select name="typ" onchange="this.form.submit()">
<option value="">{% trans "Alle Gruppen" %}</option>
{% for t in typen %}
<option value="{{ t.code }}" {% if typ_filter == t.code %}selected{% endif %}>{{ t.label }} ({{ t.n }})</option>
{% endfor %}
</select>
</label>
<noscript><button type="submit">{% trans "Anwenden" %}</button></noscript>
{% if rarity_filter or typ_filter %}
<a class="zuruecksetzen" href="?lebensraum={{ habitat|default:'alle' }}">{% trans "Zurücksetzen" %}</a>
{% endif %}
</form>
</div>
{% if cards %}
<div class="grid">
{% for card in cards %}
{% with s=card.species %}
<a class="card {% if not card.found %}locked{% endif %}"
href="{% url 'species' s.slug %}"
style="{{ s.typ_vars }}"
{% if not card.found %}title="{% trans "Noch nicht gesichtet" %}"{% endif %}>
<span class="nr">{{ s.dex_nr }}</span>
<div class="thumb">
{% if card.found and card.entry.best_sighting and card.entry.best_sighting.kachel_url %}
<img src="{{ card.entry.best_sighting.kachel_url }}" alt="{{ s.name }}" loading="lazy">
{% elif s.reference_image %}
<img src="{{ s.reference_image.url }}" alt="{{ s.name }}" loading="lazy">
{% endif %}
</div>
<div class="body">
<div class="name">{{ s.name }}</div>
<div class="meta">
{% if s.typ %}<span class="pill">{{ s.typ_label }}</span>{% endif %}
{% if card.found %}
<span class="times">×{{ card.entry.count }}</span>
{% else %}
<span class="times">+{{ s.xp }} XP</span>
{% endif %}
</div>
</div>
</a>
{% endwith %}
{% endfor %}
</div>
{% else %}
<div class="empty">{% trans "Keine Art passt zu dieser Auswahl." %}</div>
{% endif %}
{% endblock %}
+161
View File
@@ -0,0 +1,161 @@
{% extends "dex/base.html" %}
{% load i18n %}
{% block title %}{% trans "Fotos hochladen" %} — Vogelalbum{% endblock %}
{% block kopf %}
<style>
.hochladen-form {
display: grid;
gap: 16px;
max-width: 560px;
margin-bottom: 32px;
}
.hochladen-form label { display: block; font-weight: 600; margin-bottom: 6px; }
.hochladen-form input[type="file"],
.hochladen-form select,
.hochladen-form input[type="text"] {
width: 100%;
padding: 10px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
background: var(--card);
color: var(--text);
}
.hochladen-form .hinweis {
font-size: .9em;
opacity: .75;
margin-top: 4px;
}
.ort-zeile { display: flex; gap: 12px; }
.ort-zeile > div { flex: 1; }
.meldung {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
.meldung.fehler {
background: color-mix(in srgb, crimson 22%, transparent);
}
/* Die wartenden Fotos als schlichte Reihe -- sie sollen zeigen, dass
etwas passiert, und nicht mit der Sammlung konkurrieren. */
.wartend-liste { display: flex; flex-wrap: wrap; gap: 10px; }
.wartend-liste figure { margin: 0; width: 110px; }
.wartend-liste img {
width: 110px; height: 110px; object-fit: cover;
border-radius: 8px; display: block;
/* Solange nicht bestimmt ist, bleibt das Bild bewusst zurückhaltend. */
opacity: .72;
}
.wartend-liste figcaption { font-size: .78em; opacity: .7; margin-top: 4px; }
/* ⚠ Es gibt im Projekt-CSS keine allgemeine Knopfklasse — die Knöpfe im
Kopfbereich sind eigene Fälle. Also hier einer, der sich an die
Farbvariablen hält statt an den Browserstandard. */
.hochladen-form button {
padding: 10px 22px;
border: 0;
border-radius: 999px;
background: var(--accent);
color: var(--accent-on);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.hochladen-form button:hover { filter: brightness(1.08); }
/* Auf dem Telefon untereinander: zwei Koordinatenfelder nebeneinander
sind bei 390 px so schmal, dass der Platzhalter abgeschnitten wird. */
@media (max-width: 460px) {
.ort-zeile { flex-direction: column; gap: 10px; }
}
</style>
{% endblock %}
{% block content %}
<h2>{% trans "Fotos hochladen" %}</h2>
<p>
{% comment %}
⚠ In einer Zeile lassen. Ein Umbruch im Quelltext landet als ``\n`` mitten
im Schlüssel der .po-Datei — der Text ist dann nur noch mit genau diesem
Umbruch übersetzbar, und jede Einrückungsänderung macht ihn wieder deutsch.
{% endcomment %}
{% blocktrans %}Ein Vogel unterwegs? Foto hier einliefern — der Detector bestimmt es beim nächsten Durchgang und trägt es in die Sammlung ein.{% endblocktrans %}
</p>
{% if angelegt %}
<div class="meldung">
{% blocktrans count n=angelegt %}{{ n }} Foto angenommen. Es wartet auf den Detector.{% plural %}{{ n }} Fotos angenommen. Sie warten auf den Detector.{% endblocktrans %}
</div>
{% endif %}
{% if fehler %}
<div class="meldung fehler">{{ fehler }}</div>
{% endif %}
<form class="hochladen-form" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div>
<label for="bilder">{% trans "Bilder" %}</label>
{% comment %}
multiple ist Absicht: Von einem Spaziergang kommen selten einzelne
Fotos. Je Bild entsteht eine eigene Sichtung — zehn Fotos können
fünf verschiedene Arten sein.
{% endcomment %}
<input type="file" name="bilder" id="bilder" accept="image/*" multiple required>
<div class="hinweis">{% trans "Mehrere Bilder auf einmal sind möglich. Jedes wird einzeln bestimmt." %}</div>
</div>
{% if kameras %}
<div>
<label for="kamera">{% trans "Gerät" %}</label>
<select name="kamera" id="kamera">
<option value="">{% trans "— ohne Zuordnung —" %}</option>
{% for k in kameras %}
<option value="{{ k.slug }}">{{ k.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div>
<label>{% trans "Ort (freiwillig)" %}</label>
<div class="ort-zeile">
<div><input type="text" name="latitude" inputmode="decimal" placeholder="{% trans "Breite, z. B. 49.49" %}"></div>
<div><input type="text" name="longitude" inputmode="decimal" placeholder="{% trans "Länge, z. B. 8.47" %}"></div>
</div>
<div class="hinweis">
{% blocktrans %}Wird auf etwa einen Kilometer gerundet gespeichert — die Gegend, nicht die Adresse.{% endblocktrans %}
</div>
</div>
<div>
<button type="submit" class="knopf">{% trans "Hochladen" %}</button>
</div>
</form>
{% if wartende_fotos %}
<h3>
{% trans "Warten auf Bestimmung" %}
<span class="count">{{ wartend }}</span>
</h3>
{% comment %}
Sichtbar, obwohl noch nichts bestimmt ist. Wäre hier nichts zu sehen,
hielte man den Upload für fehlgeschlagen und lüde dasselbe Foto erneut
hoch — und die Doppel sortiert hinterher niemand auseinander.
{% endcomment %}
<div class="wartend-liste">
{% for s in wartende_fotos %}
<figure>
{% if s.image %}<img src="{{ s.image.url }}" alt="{% trans "Noch nicht bestimmt" %}" loading="lazy">{% endif %}
<figcaption>{{ s.started_at|date:"SHORT_DATETIME_FORMAT" }}</figcaption>
</figure>
{% endfor %}
</div>
<p class="hinweis">
{% blocktrans %}Bleiben die Bilder länger stehen, läuft der Detector nicht.{% endblocktrans %}
</p>
{% endif %}
{% endblock %}
+159
View File
@@ -0,0 +1,159 @@
{% extends "dex/base.html" %}
{% load i18n dex_extras %}
{% block title %}{% trans "Vogelalbum — Übersicht" %}{% endblock %}
{% comment %}
Der Fortschrittsbalken steht auf allen Seiten oben. Hier nicht: er ist Teil
des Blickfangs geworden. Zweimal dieselbe Leiste untereinander sähe nach
Versehen aus.
{% endcomment %}
{% block progress %}{% endblock %}
{% block content %}
{% comment %}
🔑 Die Startseite begann vorher mit vier Kacheln, von denen an einem ruhigen
Tag zwei auf null standen — "0 Besuche heute, 0 Arten heute". Das ist die
erste Seite, die ein Fremder sieht, und sie sagte ihm: hier passiert nichts.
Jetzt steht oben der letzte Gast in groß. Die Zahlen sind nicht weg, sie
stehen dort, wo sie etwas erklären: neben der Überschrift des Abschnitts,
zu dem sie gehören.
{% endcomment %}
<section class="held">
<div class="held-text">
{% comment %}
🔑 Die Überschrift kommt aus ``views.blickfang_titel`` und richtet sich
nach dem letzten Besuch. Vorher stand hier fest „Wer war heute im
Futterhaus?" — fünf Tage nach dem letzten Gast behauptete die Seite damit
etwas, das das Bild daneben widerlegte.
{% endcomment %}
<h1>{{ blickfang_titel }}</h1>
<p>{% trans "Jeder Vogel wird bestimmt, gezählt und gesammelt — aus dem Kamerabild oder vom Handyfoto. Alles auf dem eigenen Server." %}</p>
<div class="progress-bar">
<div class="level-badge">{{ progress.level }}</div>
<div style="flex:1 1 200px; min-width:170px">
<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" %}</div>
</div>
</div>
</div>
{% with letzte=recent.0 %}
{% if letzte %}
<a class="held-bild" href="{% url 'species' letzte.effective_species.slug %}">
{% if letzte.image %}
<img src="{{ letzte.image.url }}" alt="{{ letzte.effective_species.name }}">
{% elif letzte.kachel_url %}
<img src="{{ letzte.kachel_url }}" alt="{{ letzte.effective_species.name }}">
{% endif %}
<span class="frisch">{% trans "ZULETZT" %}</span>
<div class="schild">
<b>{{ letzte.effective_species.name }}</b>
<small>{{ letzte.started_at|zeitpunkt }} · {{ letzte.duration_s|floatformat:0 }}s · {% widthratio letzte.confidence 1 100 %}%</small>
</div>
</a>
{% else %}
<div class="held-leer">
{% trans "Noch kein Gast aufgezeichnet." %}<br>
{% trans "Der erste bestimmte Vogel steht hier." %}
</div>
{% endif %}
{% endwith %}
</section>
{% if new_entries %}
<h2>{% trans "Neu in der Sammlung" %} <span class="count">{% trans "letzte 7 Tage" %}</span></h2>
<div class="grid">
{% for entry in new_entries %}
<a class="card" href="{% url 'species' entry.species.slug %}"
style="{{ entry.species.typ_vars }}">
<span class="nr">{{ entry.species.dex_nr }}</span>
<div class="thumb">
{% if entry.best_sighting and entry.best_sighting.kachel_url %}
<img src="{{ entry.best_sighting.kachel_url }}" alt="{{ entry.species.name }}" loading="lazy">
{% elif entry.species.reference_image %}
<img src="{{ entry.species.reference_image.url }}" alt="{{ entry.species.name }}" loading="lazy">
{% endif %}
</div>
<div class="body">
<div class="name">{{ entry.species.name }}</div>
<div class="meta">
{% if entry.species.typ %}<span class="pill">{{ entry.species.typ_label }}</span>{% endif %}
<span class="tag-new">{% trans "Neu" %}</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% endif %}
<h2>{% trans "Zuletzt zu Besuch" %}
<span class="count">{% blocktrans with n=progress.sightings %}{{ n }} Besuche insgesamt{% endblocktrans %}</span></h2>
{% if recent %}
{% comment %}
⚠ Bildkarten statt Zeilen. Die Zeilen liefen über die ganze Breite, mit dem
Namen links und dem Prozentwert rechts — dazwischen auf einem großen
Bildschirm tausend Pixel Nichts. Eine Karte ist so breit wie ihr Inhalt.
Der erste Eintrag fehlt hier mit Absicht: der steht oben im Blickfang.
{% endcomment %}
<div class="besuche">
{% for s in recent|slice:"1:5" %}
<a class="besuch" href="{% url 'species' s.effective_species.slug %}">
{% if s.kachel_url %}
<img src="{{ s.kachel_url }}" alt="" loading="lazy">
{% else %}<div class="leer"></div>{% endif %}
<div class="f">
{% if s.xp_awarded %}<span class="xp">+{{ s.xp_awarded }} XP</span>{% endif %}
<b>{{ s.effective_species.name }}</b>
<small>
{% if s.is_first_of_species %}<span class="tag-new">{% trans "Erstfang" %}</span>
{% else %}{{ s.started_at|zeitpunkt }}{% endif %}
</small>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty">
{% trans "Noch keine Sichtung aufgezeichnet." %}<br>
{% trans "Sobald der Detector einen Vogel erkennt oder das erste Foto eingeliefert ist, erscheint hier der erste Gast." %}
</div>
{% endif %}
{% if top %}
<h2>{% trans "Stammgäste" %}</h2>
<div class="grid">
{% for entry in top %}
<a class="card" href="{% url 'species' entry.species.slug %}"
style="{{ entry.species.typ_vars }}">
<span class="nr">{{ entry.species.dex_nr }}</span>
<div class="thumb">
{% if entry.best_sighting and entry.best_sighting.kachel_url %}
<img src="{{ entry.best_sighting.kachel_url }}" alt="{{ entry.species.name }}" loading="lazy">
{% elif entry.species.reference_image %}
<img src="{{ entry.species.reference_image.url }}" alt="{{ entry.species.name }}" loading="lazy">
{% endif %}
</div>
<div class="body">
<div class="name">{{ entry.species.name }}</div>
<div class="meta">
{% if entry.species.typ %}<span class="pill">{{ entry.species.typ_label }}</span>{% endif %}
<span class="times">×{{ entry.count }}</span>
</div>
</div>
</a>
{% endfor %}
</div>
{% endif %}
{% endblock %}
+135
View File
@@ -0,0 +1,135 @@
{% extends "dex/base.html" %}
{% load i18n l10n %}
{% block title %}{% trans "Live" %} — Vogelalbum{% endblock %}
{% block content %}
<h2>{% trans "Live" %}
<span class="count">
{% blocktrans count n=cameras|length %}{{ n }} Kamera{% plural %}{{ n }} Kameras{% endblocktrans %}
</span>
</h2>
<div class="live-grid">
{% for kamera in cameras %}
<figure class="live-karte">
{% comment %}
Das Bild kommt vom Live-Verteiler des Detectors, nicht von der Kamera —
die liefert nur einen Stream, und den braucht die Erkennung. Der Browser
lädt es direkt, der Server reicht nichts durch: sonst hinge an jedem
Zuschauer eine offene Verbindung in der Web-App.
{% endcomment %}
<div class="live-bild">
<img alt="{{ kamera.name }}"
data-quelle="{{ kamera.live_bild_url }}"
data-rate="{{ kamera.live_bildrate|unlocalize }}">
{% comment %}
Der Name liegt AUF dem Bild: In einer Reihe gleich aussehender
Gartenbilder erkennt man sonst nicht, welche Kamera welche ist —
und beim Scrollen ist die Bildunterschrift oft aus dem Blick.
⚠ Diese lange Kommentarform benutzen, nicht die kurze mit der
Raute: Die kurze gilt nur für EINE Zeile. Über mehrere Zeilen wird
sie nicht entfernt, sondern steht wörtlich auf der Seite. Genau das
ist am 23.09.2026 passiert, und man sieht es erst im Browser.
{% endcomment %}
<span class="warte">Bild wird geholt &hellip;</span>
<span class="live-schild">{{ kamera.name }}</span>
</div>
<figcaption>
<strong>{{ kamera.name }}</strong>
{% if kamera.location %}<span class="live-ort">{{ kamera.location }}</span>{% endif %}
</figcaption>
</figure>
{% endfor %}
</div>
<p class="d" style="margin-top:14px">
{% trans "Bleibt das Bild schwarz, läuft der Detector nicht — die Erkennung verteilt das Livebild aus ihrem eigenen Bildstrom." %}
</p>
<script>
/*
* Einzelbilder statt Dauerstrom.
*
* 🔑 Am 24.09.2026 gemessen und an der Anlage bestaetigt: EINE Kamera
* erscheint sofort, ZWEI nebeneinander lassen die Seite haengen. Ein
* MJPEG-Strom belegt seine Verbindung fuer immer; mit zwei solchen kommt
* mancher Browser durcheinander -- auf dem Handy besonders.
*
* Ein Einzelbild ist eine kurze, gewoehnliche Anfrage. Sie blockiert
* nichts, und nach einem Fehler wird beim naechsten Mal einfach wieder
* gefragt. Wie oft, sagt die Bildrate der Kamera aus dem Backend.
*
* ⚠ Der Zeitstempel an der Adresse ist Pflicht: Ohne ihn liefert der
* Browser sein zwischengespeichertes Bild aus und man sieht ein
* Standbild, das aussieht wie ein Livebild.
*/
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".live-bild img").forEach(function (bild) {
var warte = bild.parentElement.querySelector(".warte");
var quelle = bild.dataset.quelle;
var rate = parseFloat(bild.dataset.rate) || 2;
var pause = Math.max(200, 1000 / rate);
var laeuft = false;
if (!quelle) return;
bild.addEventListener("load", function () {
if (warte) warte.style.display = "none";
laeuft = false;
});
bild.addEventListener("error", function () {
if (warte) {
warte.style.display = "";
warte.textContent = "Kein Bild — neuer Versuch …";
}
laeuft = false;
});
function holen() {
// Nicht nachlegen, solange das vorige Bild noch unterwegs ist --
// sonst stapeln sich Anfragen, wenn die Strecke langsam ist.
if (!laeuft) {
laeuft = true;
bild.src = quelle + "?t=" + Date.now();
}
setTimeout(holen, pause);
}
holen();
});
});
</script>
<style>
.live-grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
.live-karte{margin:0;background:#000;border-radius:10px;overflow:hidden}
.live-karte img{display:block;width:100%;height:auto}
/* ⚠ Die Karte ist schwarz, die Schriftfarbe der Seite aber fast auch
(--text: #16211a). Ohne eigene Farbe stand die Beschriftung schwarz
auf schwarz — sie war da und trotzdem unsichtbar. */
.live-karte figcaption{padding:8px 12px;display:flex;gap:10px;
align-items:baseline;color:#fff}
.live-karte figcaption .live-ort{color:#9fb3a6;font-size:13px}
/* ⚠ Feste Form, auch wenn kein Bild da ist. Ohne das faellt die Karte
in sich zusammen, sobald der Bildstrom abreisst -- dann steht da nur
noch die Beschriftung auf einem schwarzen Strich, und es sieht nach
einem kaputten Programm aus statt nach einer Kamera ohne Bild. */
.live-bild{position:relative;aspect-ratio:16/9;background:#000;
display:flex;align-items:center;justify-content:center}
.live-bild img{position:absolute;inset:0;width:100%;height:100%;
object-fit:cover}
.live-bild .warte{color:#8a8a94;font-size:14px;text-align:center;
padding:0 20px;line-height:1.5}
.live-schild{position:absolute;top:10px;left:10px;
padding:4px 10px;border-radius:999px;
background:rgba(0,0,0,.55);color:#fff;
font-size:13px;font-weight:600;letter-spacing:.01em;
/* Damit der Name auch auf hellem Himmel lesbar bleibt. */
backdrop-filter:blur(4px);text-shadow:0 1px 2px rgba(0,0,0,.6)}
</style>
{% endblock %}
+105
View File
@@ -0,0 +1,105 @@
{% extends "dex/base.html" %}
{% load dex_extras i18n %}
{% block title %}{% trans "Sichtungen" %} — Vogelalbum{% endblock %}
{% block kopf %}
<style>
/* Der Einlieferungs-Knopf sitzt in der Zeile der Ueberschrift, rechts.
Auf dem Telefon rutscht er darunter, statt die Zeile zu sprengen. */
.kopf-zeile {
display: flex; align-items: baseline; justify-content: space-between;
flex-wrap: wrap; gap: 10px;
}
.kopf-zeile h2 { margin: 0; }
.hochladen-link {
padding: 7px 16px; border-radius: 999px;
background: var(--accent); color: var(--accent-on);
font-weight: 600; font-size: .92em; text-decoration: none;
white-space: nowrap;
}
.hochladen-link:hover { filter: brightness(1.08); }
</style>
{% endblock %}
{% block content %}
<div class="kopf-zeile">
<h2>{% trans "Sichtungen" %} <span class="count">{% blocktrans with n=total %}{{ n }} insgesamt{% endblocktrans %}</span></h2>
{% comment %}
Nur fuer Angemeldete. Ein oeffentlicher Besucher soll keinen Knopf sehen,
der ihn bloss ins Login schickt -- und einliefern darf er ohnehin nicht.
{% endcomment %}
{% if user.is_staff %}
<a href="{% url 'hochladen' %}" class="hochladen-link">{% trans "Foto hochladen" %}</a>
{% endif %}
</div>
<div class="filters">
<a href="{% url 'sightings' %}" {% if not nur_bestimmt %}class="on"{% endif %}>{% trans "Alle Besuche" %}</a>
<a href="?nur_bestimmt=1" {% if nur_bestimmt %}class="on"{% endif %}>{% trans "Nur bestimmte Arten" %}</a>
</div>
{% if cameras %}
<div class="filters">
<a href="?{% if nur_bestimmt %}nur_bestimmt=1{% endif %}" {% if not kamera %}class="on"{% endif %}>{% trans "Alle Kameras" %}</a>
{% for c in cameras %}
<a href="?kamera={{ c.slug }}{% if nur_bestimmt %}&nur_bestimmt=1{% endif %}"
{% if kamera == c.slug %}class="on"{% endif %}>
{% if c.is_stale %}⚠ {% endif %}{{ c.name }}
</a>
{% endfor %}
</div>
{% endif %}
{% if sightings %}
<div class="rows">
{% for s in sightings %}
<div class="row">
{% if s.kachel_url %}
{# Klick zeigt das grosse Foto -- siehe base.html #}
<img class="shot" src="{{ s.kachel_url }}" alt="" loading="lazy"
{% if s.image %}data-gross="{{ s.image.url }}"
data-beschriftung="{% if s.effective_species %}{{ s.effective_species.name }} · {% endif %}{{ s.started_at|date:"SHORT_DATETIME_FORMAT" }}{% if s.camera %} · {{ s.camera.name }}{% endif %}"
style="cursor:zoom-in"{% endif %}>
{% else %}<div class="shot"></div>{% endif %}
<div>
<div class="who">
{% if s.effective_species %}
<a href="{% url 'species' s.effective_species.slug %}">{{ s.effective_species.name }}</a>
{% if s.confirmed_species %}<span class="pill ghost">{% trans "von Hand" %}</span>{% endif %}
{% else %}
<span style="color:var(--muted)">{% trans "Unbestimmt" %}</span>
{% endif %}
{% if s.is_first_of_species %}<span class="tag-new">{% trans "Erstfang" %}</span>{% endif %}
</div>
<div class="when">
{{ s.started_at|date:"DATETIME_FORMAT" }} · {{ s.duration_s|floatformat:0 }}s
{% if s.camera %} · {{ s.camera.name }}{% endif %}
</div>
{% with alt=s.votes|alternativen %}
{% if alt %}
<div class="when" style="margin-top:2px">
{% trans "Alternativen" %}:
{% for a in alt %}{{ a.species.name }} {{ a.anteil }}%{% if not forloop.last %}, {% endif %}{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
<div class="right">
<div class="conf">{{ s.confidence|prozent }}%</div>
{% if s.xp_awarded %}<div class="xp">+{{ s.xp_awarded }} XP</div>{% endif %}
</div>
</div>
{% endfor %}
</div>
<div class="pager">
{% if page > 1 %}<a href="?seite={{ page|add:'-1' }}{% if nur_bestimmt %}&nur_bestimmt=1{% endif %}">← {% trans "Neuer" %}</a>{% endif %}
{% if has_next %}<a href="?seite={{ page|add:'1' }}{% if nur_bestimmt %}&nur_bestimmt=1{% endif %}">{% trans "Älter" %} →</a>{% endif %}
</div>
{% else %}
<div class="empty">{% trans "Noch nichts aufgezeichnet." %}</div>
{% endif %}
{% endblock %}
+149
View File
@@ -0,0 +1,149 @@
{% extends "dex/base.html" %}
{% load dex_extras i18n %}
{% block title %}{{ species.name }} — Vogelalbum{% endblock %}
{% block content %}
{# Die Typfarbe faerbt die ganze Seite: Hero, Balken, Initiale, Diagramm. #}
<div style="{{ species.typ_vars }}">
<div class="blaettern">
{% if vorher %}<a href="{% url 'species' vorher.slug %}">← {{ vorher.dex_nr }} {{ vorher.name }}</a>{% else %}<span></span>{% endif %}
{% if nachher %}<a href="{% url 'species' nachher.slug %}">{{ nachher.dex_nr }} {{ nachher.name }} →</a>{% endif %}
</div>
<div class="species-hero">
<div class="hero-grid">
<div>
<div class="hero-photo">
{% if entry and entry.best_sighting and entry.best_sighting.image %}
<img src="{{ entry.best_sighting.image.url }}" alt="{{ species.name }}">
{% elif species.reference_image %}
<img src="{{ species.reference_image.url }}" alt="{{ species.german }}"
style="filter: saturate(.7) brightness(.72)">
{% endif %}
</div>
<div class="sub" style="margin-top:9px">
{% if entry and entry.best_sighting and entry.best_sighting.image %}
{% trans "Bestes eigenes Foto" %} · {{ entry.best_sighting.started_at|date:"SHORT_DATETIME_FORMAT" }}
{% elif species.reference_image %}
{% trans "Referenzfoto — diese Art war noch nicht vor unserer Kamera." %}
{% if species.reference_credit %}
{% trans "Foto" %}: <a href="{{ species.reference_source }}" rel="nofollow noopener" target="_blank"
style="text-decoration:underline">{{ species.reference_credit }}</a>{% if species.reference_license %}, {{ species.reference_license }}{% endif %}
{% endif %}
{% endif %}
</div>
</div>
<div>
<div class="dexnr">{{ species.dex_nr }}</div>
<h1>{{ species.name }}</h1>
{% if species.kategorie_lokal %}<div class="kategorie">{{ species.kategorie_lokal }}</div>{% endif %}
<div class="sci">{{ species.scientific }}{% if species.zweitname %} · {{ species.zweitname }}{% endif %}</div>
<div class="badge-row">
{% if species.typ %}<span class="pill">{{ species.typ_label }}</span>{% endif %}
<span class="pill rar" style="background:{{ species.rarity_color }}">{{ species.rarity_label }}</span>
{% if species.zug %}<span class="pill ghost">{{ species.zug_label }}</span>{% endif %}
<span class="pill ghost">{{ species.xp }} XP</span>
{% if entry %}<span class="pill ghost">{% blocktrans count n=entry.count %}{{ n }}× seen{% plural %}{{ n }}× seen{% endblocktrans %}</span>{% endif %}
</div>
{% if species.balken %}
<div class="werte">
{% for b in species.balken %}
<div class="wert">
<div class="k">{{ b.label }}</div>
<div class="track"><div class="fill" style="width:{{ b.pct }}%"></div></div>
<div class="v">{{ b.wert }}</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
{% if species.absaetze %}
<h2>{% trans "Steckbrief" %}</h2>
<div class="prose">
{% for p in species.absaetze %}<p>{{ p }}</p>{% endfor %}
</div>
{% elif species.fact %}
<h2>{% trans "Steckbrief" %}</h2>
<div class="prose"><p>{{ species.fact }}</p></div>
{% endif %}
<div class="fakten">
{% if species.lebensraum_lokal %}
<div class="fakt"><div class="k">{% trans "Lebensraum" %}</div><div class="v">{{ species.lebensraum_lokal }}</div></div>
{% endif %}
{% if species.nahrung_lokal %}
<div class="fakt"><div class="k">{% trans "Nahrung" %}</div><div class="v">{{ species.nahrung_lokal }}</div></div>
{% endif %}
{% if species.stimme_lokal %}
<div class="fakt"><div class="k">{% trans "Stimme" %}</div><div class="v">{{ species.stimme_lokal }}</div></div>
{% endif %}
{% if species.zug %}
<div class="fakt"><div class="k">{% trans "Zugverhalten" %}</div><div class="v">{{ species.zug_label }}</div></div>
{% endif %}
</div>
{% if aehnliche %}
<h2>{% trans "Verwechslungsgefahr" %} <span class="count">{% trans "Arten, die man nicht auf Anhieb unterscheidet" %}</span></h2>
<div class="verwechslung">
{% for a in aehnliche %}
<a class="vw" href="{% url 'species' a.slug %}">
{% if a.reference_image %}<img src="{{ a.reference_image.url }}" alt="" loading="lazy">{% endif %}
<span class="n">{{ a.name }}</span>
</a>
{% endfor %}
</div>
{% endif %}
{% if entry %}
<h2>{% blocktrans with n=species.name %}Wann ist {{ n }} da?{% endblocktrans %} <span class="count">{% trans "Besuche über den Tag" %}</span></h2>
<div class="hours">
{% for h in hours %}<div style="height:{{ h.pct }}%" title="{{ h.h }} Uhr: {{ h.n }}"></div>{% endfor %}
</div>
<div class="hours-axis"><span>{% trans "0 Uhr" %}</span><span>6</span><span>12</span><span>18</span><span>23</span></div>
{% endif %}
{% if sightings %}
<h2>{% trans "Alle Besuche" %} <span class="count">{% blocktrans with n=entry.count %}{{ n }} insgesamt{% endblocktrans %}</span></h2>
<div class="rows">
{% for s in sightings %}
<div class="row">
{% if s.kachel_url %}
<img class="shot" src="{{ s.kachel_url }}" alt="" loading="lazy"
{% if s.image %}data-gross="{{ s.image.url }}"
data-beschriftung="{{ species.name }} · {{ s.started_at|date:"SHORT_DATETIME_FORMAT" }}{% if s.camera %} · {{ s.camera.name }}{% endif %}"
style="cursor:zoom-in"{% endif %}>
{% else %}<div class="shot"></div>{% endif %}
<div>
<div class="who">
{{ s.started_at|date:"DATETIME_FORMAT" }}
{% if s.is_first_of_species %}<span class="tag-new">{% trans "Erstfang" %}</span>{% endif %}
</div>
<div class="when">
{{ s.duration_s|floatformat:0 }}s · {% blocktrans with a=s.frames_with_bird b=s.frames_total %}{{ a }} von {{ b }} Bildern mit Vogel{% endblocktrans %}
{% if s.camera %} · {{ s.camera.name }}{% endif %}
</div>
</div>
<div class="right">
<div class="conf">{{ s.confidence|prozent }}%</div>
{% if s.xp_awarded %}<div class="xp">+{{ s.xp_awarded }} XP</div>{% endif %}
</div>
</div>
{% endfor %}
</div>
{% elif not entry %}
<div class="empty" style="margin-top:24px">
<strong style="color:var(--text); font-size:17px">{% trans "Noch nicht gesammelt." %}</strong><br>
{% blocktrans with n=species.name %}Sobald {{ n }} vor der Kamera landet, wird dieser Eintrag freigeschaltet — mit eurem eigenen Foto.{% endblocktrans %}
</div>
{% endif %}
</div>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
{% 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>Vogelalbum</title>
<link rel="stylesheet" href="{% static_v 'css/vogelalbum.css' %}">
<style>
/* Im iframe gibt es keine Navigation und keinen Seitenrand. */
body { background: transparent; }
.wrap { padding: 0; max-width: none; }
.widget { display: flex; gap: 14px; align-items: center;
background: var(--card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px; }
.widget img { width: 96px; height: 74px; object-fit: cover;
border-radius: 10px; background: #0b120e; flex-shrink: 0; }
</style>
</head>
<body>
<div class="wrap">
<div class="widget">
{% if latest %}
{% if latest.kachel_url %}<img src="{{ latest.kachel_url }}" alt="{{ latest.effective_species.name }}">{% endif %}
<div style="min-width:0">
<div class="sub">{% trans "Zuletzt gesehen" %}</div>
<div style="font-weight:700; font-size:18px; margin:2px 0 4px">{{ latest.effective_species.name }}</div>
<div class="sub">{{ latest.started_at|zeitpunkt }} · {% widthratio latest.confidence 1 100 %}% {% trans "sicher" %}</div>
</div>
{% else %}
<div>
<div class="sub">Vogelalbum</div>
<div style="font-weight:700; font-size:18px">{% trans "Noch kein Gast" %}</div>
</div>
{% endif %}
<div style="margin-left:auto; text-align:right; flex-shrink:0">
<div style="font-weight:800; font-size:22px">{{ progress.species_seen }}<span class="sub" style="font-size:15px">/{{ progress.species_total }}</span></div>
<div class="sub">{% trans "Arten" %}</div>
</div>
</div>
</div>
</body>
</html>