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,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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 …</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user