Compare commits

..

No commits in common. "031c6028ac0040912a563ac2cbd4ea765b2bbf3c" and "062794c61a6e99597eaec0bc8824af567eaaadd9" have entirely different histories.

19 changed files with 74 additions and 1445 deletions

View file

@ -1072,19 +1072,6 @@ def _migrate(conn_factory):
pass
logger.info("Migration: wiki_rassen Anreicherungs-Felder bereit.")
# Moderation-Logging: resolved_by/at für forum_reports, verified_by/at/reject für wiki_zuchter
for table, col, typedef in [
("forum_reports", "resolved_by", "INTEGER"),
("forum_reports", "resolved_at", "TEXT"),
("wiki_zuchter", "verified_by", "INTEGER"),
("wiki_zuchter", "verified_at", "TEXT"),
("wiki_zuchter", "reject_reason", "TEXT"),
]:
try:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
except Exception:
pass
# Wiki: Züchter-Verzeichnis
conn.executescript("""
CREATE TABLE IF NOT EXISTS wiki_zuchter (
@ -1644,16 +1631,3 @@ def _migrate(conn_factory):
conn.execute("UPDATE training_exercises SET js_exercise_id=? WHERE id=?", (js_id, row['id']))
conn.execute("CREATE INDEX IF NOT EXISTS idx_te_js_id ON training_exercises(js_exercise_id)")
logger.info("Migration: training_exercises.js_exercise_id hinzugefügt, 'Fuß' bereinigt.")
# Hund des Monats — dauerhafte Gewinner-Tabelle
conn.executescript("""
CREATE TABLE IF NOT EXISTS hund_des_monats_wins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
monat TEXT NOT NULL,
stimmen INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(dog_id, monat)
);
CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id);
""")

View file

@ -1429,13 +1429,6 @@ async def knigge_page():
# ------------------------------------------------------------------
# /presse — Presseseite
# ------------------------------------------------------------------
@app.get("/presse")
async def presse():
return FileResponse(f"{STATIC_DIR}/presse.html", headers={"Cache-Control": "max-age=3600"})
# /partner — Influencer-Landingpage
# ------------------------------------------------------------------
@app.get("/partner")

View file

@ -97,40 +97,6 @@ class ThreadAdminPatch(BaseModel):
is_deleted: Optional[int] = None
# ------------------------------------------------------------------
# GET /api/admin/action-items
# ------------------------------------------------------------------
@router.get("/action-items")
async def action_items(user=Depends(require_mod)):
with db() as conn:
jobs = conn.execute(
"SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')"
).fetchone()[0]
breeders = conn.execute(
"SELECT COUNT(*) FROM users WHERE breeder_status='pending'"
).fetchone()[0]
reports = conn.execute(
"SELECT COUNT(*) FROM forum_reports WHERE resolved=0"
).fetchone()[0]
fotos = conn.execute(
"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'"
).fetchone()[0]
poi_edits = conn.execute(
"SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'"
).fetchone()[0]
users_today = conn.execute(
"SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')"
).fetchone()[0]
return {
"jobs_pending": jobs,
"breeder_pending": breeders,
"reports_open": reports,
"fotos_pending": fotos,
"poi_edits_pending": poi_edits,
"users_today": users_today,
}
# ------------------------------------------------------------------
# GET /api/admin/stats
# ------------------------------------------------------------------

View file

@ -75,21 +75,6 @@ async def list_dogs(user=Depends(get_current_user)):
d = dict(r)
d["is_guest"] = True
result.append(d)
# HdM-Siege pro Hund anhängen
if result:
dog_ids = [d["id"] for d in result]
with db() as conn:
wins_rows = conn.execute(
f"SELECT dog_id, monat FROM hund_des_monats_wins WHERE dog_id IN ({','.join('?'*len(dog_ids))}) ORDER BY monat DESC",
dog_ids,
).fetchall()
wins_map: dict[int, list[str]] = {}
for w in wins_rows:
wins_map.setdefault(w["dog_id"], []).append(w["monat"])
for d in result:
d["hdm_wins"] = wins_map.get(d["id"], [])
return result

View file

@ -1,5 +1,4 @@
"""BAN YARO — Moderations-Panel Backend"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from database import db
from auth import get_current_user
@ -70,19 +69,17 @@ async def mod_stats(user=Depends(require_moderator)):
async def mod_reports(user=Depends(require_moderator)):
with db() as conn:
rows = conn.execute("""
SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved,
r.created_at, r.resolved_at,
u.name AS melder_name,
m.name AS resolved_by_name,
SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, r.created_at,
u.name AS melder_name,
CASE r.target_type
WHEN 'thread' THEN (SELECT t.titel FROM forum_threads t WHERE t.id=r.target_id)
WHEN 'post' THEN (SELECT SUBSTR(p.text,1,80) FROM forum_posts p WHERE p.id=r.target_id)
END AS content_preview
FROM forum_reports r
LEFT JOIN users u ON u.id=r.user_id
LEFT JOIN users m ON m.id=r.resolved_by
ORDER BY r.resolved ASC, r.created_at DESC
LIMIT 200
WHERE r.resolved=0
ORDER BY r.created_at DESC
LIMIT 100
""").fetchall()
return [dict(r) for r in rows]
@ -100,12 +97,8 @@ async def mod_resolve_report(rid: int, user=Depends(require_moderator)):
raise HTTPException(404, "Meldung nicht gefunden.")
new_state = 0 if r["resolved"] else 1
conn.execute(
"""UPDATE forum_reports SET resolved=?, resolved_by=?, resolved_at=?
WHERE id=?""",
(new_state,
user["id"] if new_state else None,
datetime.utcnow().isoformat() if new_state else None,
rid)
"UPDATE forum_reports SET resolved=? WHERE id=?",
(new_state, rid)
)
return {"ok": True}
@ -196,19 +189,17 @@ async def mod_patch_user(uid: int, data: dict, user=Depends(require_moderator)):
async def mod_fotos(user=Depends(require_moderator)):
with db() as conn:
rows = conn.execute("""
SELECT s.id, s.foto_url, s.status, s.created_at,
s.reviewed_at, s.reject_reason,
SELECT s.id, s.foto_url, s.created_at,
COALESCE(s.rights_confirmed, 0) AS rights_confirmed,
u.name AS user_name,
m.name AS reviewed_by_name,
r.name AS rasse_name, r.slug AS rasse_slug,
u.name AS user_name,
r.name AS rasse_name, r.slug AS rasse_slug,
r.foto_url AS aktuell_foto
FROM wiki_foto_submissions s
LEFT JOIN users u ON u.id = s.user_id
LEFT JOIN users m ON m.id = s.reviewed_by
LEFT JOIN wiki_rassen r ON r.id = s.rasse_id
ORDER BY s.status ASC, s.created_at ASC
LIMIT 200
WHERE s.status = 'pending'
ORDER BY s.created_at ASC
LIMIT 50
""").fetchall()
return [dict(r) for r in rows]
@ -237,13 +228,11 @@ async def mod_poi_edits(user=Depends(require_moderator)):
SELECT e.id, e.osm_id, e.poi_name, e.field,
e.old_value, e.new_value, e.status,
e.created_at, e.resolved_at,
u.name AS einreicher_name,
m.name AS mod_name
u.name AS einreicher_name
FROM osm_poi_edits e
JOIN users u ON u.id = e.user_id
LEFT JOIN users m ON m.id = e.mod_id
ORDER BY e.status ASC, e.created_at DESC
LIMIT 200
LIMIT 100
""").fetchall()
return [dict(r) for r in rows]

View file

@ -94,71 +94,6 @@ _SEED_FILME = [
{"id": "white-fang", "titel": "Wolfsblut", "originaltitel": "White Fang", "jahr": 1991, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Wolf-Hund-Hybrid", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Ethan Hawke: Ein Wolf-Hund-Hybrid findet in Alaska zwischen Wildnis und Menschenwelt seinen Platz.", "bild_emoji": "❄️", "imdb_rating": 6.7},
{"id": "because-of-winn-dixie","titel": "Winn-Dixie", "originaltitel": "Because of Winn-Dixie", "jahr": 2005, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein einsames Mädchen findet einen streunenden Hund im Supermarkt — und mit ihm eine ganze Gemeinschaft.", "bild_emoji": "🛒", "imdb_rating": 6.4},
{"id": "lassie-2005", "titel": "Lassie (2005)", "jahr": 2005, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Britische Neuverfilmung mit Peter O'Toole. Lassie flieht aus Schottland und findet den langen Weg nach Yorkshire. Atmosphärisch.", "bild_emoji": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", "imdb_rating": 6.7},
# ── Neue Einträge: Animation ──────────────────────────────────────
{"id": "all-dogs-go-to-heaven", "titel": "Alle Hunde kommen in den Himmel", "originaltitel": "All Dogs Go to Heaven", "jahr": 1989, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": True, "beschreibung": "Don Bluth-Klassiker: Gauner-Hund Charlie entkommt dem Himmel und sucht Rache — bis er sich in ein Waisenmädchen verliebt.", "bild_emoji": "😇", "imdb_rating": 6.8},
{"id": "all-dogs-go-heaven-2", "titel": "Alle Hunde kommen in den Himmel 2", "originaltitel": "All Dogs Go to Heaven 2", "jahr": 1996, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Charlie kehrt aus dem Himmel zurück nach San Francisco — mit Charlie Sheen als Synchronstimme.", "bild_emoji": "😇", "imdb_rating": 5.4},
{"id": "oliver-and-company", "titel": "Oliver & Co.", "originaltitel": "Oliver & Company", "jahr": 1988, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Verschiedene (Dodger = Mischling)", "stirbt_der_hund": False, "beschreibung": "Disney modernisiert Oliver Twist: Ein obdachloser Kater schließt sich einer Hunde-Gang unter Anführer Dodger im New York der 1980er an.", "bild_emoji": "🎸", "imdb_rating": 6.7, "streaming": "Disney+"},
{"id": "peanuts-movie", "titel": "Die Peanuts — Der Film", "originaltitel": "The Peanuts Movie", "jahr": 2015, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Snoopy und Charlie Brown auf der großen Leinwand. Perfekt für alle, die mit dem Kultcomic aufgewachsen sind.", "bild_emoji": "✈️", "imdb_rating": 7.0, "streaming": "Disney+"},
{"id": "clifford-big-red-dog", "titel": "Clifford — Der große rote Hund", "originaltitel": "Clifford the Big Red Dog", "jahr": 2021, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Mischling (riesig)", "stirbt_der_hund": False, "beschreibung": "Der riesige rote Hund aus dem Kinderbuchklassiker kommt ins Kino — Chaos und Herz für die ganze Familie.", "bild_emoji": "🔴", "imdb_rating": 5.4, "streaming": "Amazon Prime"},
{"id": "101-dalmatians-1996", "titel": "101 Dalmatiner (1996)", "originaltitel": "101 Dalmatians", "jahr": 1996, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Glenn Close als unvergessliche Cruella de Vil im Live-Action-Remake des Disney-Klassikers — böse, schrill und absolut unterhaltsam.", "bild_emoji": "🐾", "imdb_rating": 5.7, "streaming": "Disney+"},
{"id": "102-dalmatians", "titel": "102 Dalmatiner", "originaltitel": "102 Dalmatians", "jahr": 2000, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Cruella ist scheinbar geläutert — aber die Dalmatiner-Welpen sind wieder in Gefahr. Fortsetzung mit Glenn Close.", "bild_emoji": "🐾", "imdb_rating": 4.9, "streaming": "Disney+"},
{"id": "lady-and-tramp-2019", "titel": "Susi und Strolch (2019)", "originaltitel": "Lady and the Tramp", "jahr": 2019, "genre": "Familie/Romanze", "typ": "film", "hund_rasse": "Cocker Spaniel / Mischling", "stirbt_der_hund": False, "beschreibung": "Disney+ Live-Action-Remake mit echten Hunden: Die ikonische Spaghetti-Szene neu inszeniert, herzlich und mit modernem Charme.", "bild_emoji": "🍝", "imdb_rating": 6.4, "streaming": "Disney+"},
{"id": "my-dog-tulip", "titel": "Mein Hund Tulip", "originaltitel": "My Dog Tulip", "jahr": 2009, "genre": "Animation/Drama", "typ": "film", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": True, "beschreibung": "Animierter Arthouse-Film nach J.R. Ackerley: Ein eigenbrötlerischer Engländer und die vorbehaltlose Liebe zu seiner Schäferhündin Tulip.", "bild_emoji": "🌷", "imdb_rating": 7.0},
{"id": "bluey", "titel": "Bluey", "jahr": 2018, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Blue Heeler", "stirbt_der_hund": False, "beschreibung": "Australische Kinder-Animationsserie über eine Blue-Heeler-Familie, die weltweit Eltern und Kinder gleichermaßen begeistert. Meistgeliebte Kinderserie des 21. Jahrhunderts.", "bild_emoji": "💙", "imdb_rating": 9.0, "streaming": "Disney+"},
{"id": "strays-2023", "titel": "Strays — Lass uns Hunde sein", "originaltitel": "Strays", "jahr": 2023, "genre": "Animation/Komödie", "typ": "film", "hund_rasse": "Mischling / Australian Shepherd", "stirbt_der_hund": False, "beschreibung": "Komplett obszöne Erwachsenen-Trickfilm-Komödie: Verlassener Hund will mit neuen Hundefreunden Rache am Herrchen nehmen. Nicht für Kinder!","bild_emoji": "🤬", "imdb_rating": 5.8, "streaming": "Amazon Prime"},
# ── Neue Einträge: Familie/Drama ──────────────────────────────────
{"id": "hotel-for-dogs", "titel": "Hotel für Hunde", "originaltitel": "Hotel for Dogs", "jahr": 2009, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Zwei Geschwister verwandeln ein leerstehendes Hotel in ein geheimes Paradies für Straßenhunde. Herzerwärmende Familienunterhaltung.", "bild_emoji": "🏨", "imdb_rating": 5.8},
{"id": "snow-dogs", "titel": "Snowdogs", "originaltitel": "Snow Dogs", "jahr": 2002, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Siberian Husky", "stirbt_der_hund": False, "beschreibung": "Cuba Gooding Jr. erbt eine Schlittenhunde-Meute in Alaska und muss erst lernen, mit ihnen umzugehen. Leichte Disney-Komödie.", "bild_emoji": "🛷", "imdb_rating": 4.2, "streaming": "Disney+"},
{"id": "a-dogs-way-home", "titel": "Auf dem Heimweg — Lassie und Ich", "originaltitel": "A Dog's Way Home", "jahr": 2019, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Pit-Bull-Mischling", "stirbt_der_hund": False, "beschreibung": "Hündin Bella verliert sich 400 Meilen von zu Hause entfernt und kämpft sich durch alle Widrigkeiten zurück zu ihrem Herrchen.", "bild_emoji": "🏡", "imdb_rating": 6.7, "streaming": "Netflix"},
{"id": "fluke", "titel": "Fluke — Das fremde Ich", "originaltitel": "Fluke", "jahr": 1995, "genre": "Drama/Fantasy", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Mann stirbt und wird als Hund wiedergeboren — und kehrt zu seiner Familie zurück. Ungewöhnliches Dramafantasy mit tiefem emotionalem Kern.", "bild_emoji": "🔄", "imdb_rating": 6.2},
{"id": "zeus-and-roxanne", "titel": "Zeus und Roxanne", "originaltitel": "Zeus and Roxanne", "jahr": 1997, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Hund und ein Delfin werden beste Freunde — und bringen dabei auch ihre Besitzer zusammen. Charmante Familienunterhaltung der 90er.", "bild_emoji": "🐬", "imdb_rating": 5.2},
{"id": "benji-2018", "titel": "Benji (2018)", "originaltitel": "Benji", "jahr": 2018, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Netflix-Remake des Klassikers: Der streunende Hund Benji rettet erneut Kinder aus gefährlichen Händen — jetzt für eine neue Generation.", "bild_emoji": "🐾", "imdb_rating": 6.3, "streaming": "Netflix"},
{"id": "ugly-dachshund", "titel": "Der hässliche Dackel", "originaltitel": "The Ugly Dachshund", "jahr": 1966, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutscher Dogge / Dackel", "stirbt_der_hund": False, "beschreibung": "Disney-Klassiker: Eine Harlekindogge wird von Dackeln aufgezogen und denkt, sie sei selbst ein Dackel. Harmlose Familienkomödie.", "bild_emoji": "😅", "imdb_rating": 6.4},
{"id": "shiloh", "titel": "Shiloh — Mein treuer Freund", "originaltitel": "Shiloh", "jahr": 1996, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Junge in West Virginia rettet einen misshandelten Beagle vor seinem brutalen Besitzer — eine Geschichte über Mut und Gewissen.", "bild_emoji": "🌿", "imdb_rating": 6.7},
{"id": "iron-will", "titel": "Iron Will", "jahr": 1994, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": False, "beschreibung": "Junger Mann rettet die Farm seiner Familie mit einem gewagten Schlittenhunde-Rennen von Kanada nach Minnesota. Inspirierend und episch.", "bild_emoji": "🏆", "imdb_rating": 6.7, "streaming": "Disney+"},
{"id": "belle-et-sebastien", "titel": "Belle und Sébastien", "originaltitel": "Belle et Sébastien", "jahr": 2013, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Französischer Familienfilm: Ein Waisenjunge und die riesige Berghündin Belle sind beste Freunde in den Alpen des Zweiten Weltkriegs.", "bild_emoji": "🏔️", "imdb_rating": 7.0},
{"id": "dog-of-flanders", "titel": "Ein Hund von Flandern", "originaltitel": "A Dog of Flanders", "jahr": 1999, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Bouvier des Flandres", "stirbt_der_hund": True, "beschreibung": "Bewegende Verfilmung des Klassikers: Ein armer Junge in Belgien und sein Hund träumen von Kunst und Würde — mit tragischem Ende.", "bild_emoji": "🎨", "imdb_rating": 7.0},
{"id": "underdog-2007", "titel": "Underdog — Ein Held auf vier Pfoten", "originaltitel": "Underdog", "jahr": 2007, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Laborhund erhält Superkräfte und wird zum maskierten Superhelden der Stadt. Leichte Disney-Familienkomödie nach der Zeichentrickserie.", "bild_emoji": "🦸", "imdb_rating": 5.1},
{"id": "bingo-1991", "titel": "Bingo", "jahr": 1991, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Zirkushund reist quer durch Amerika, um seinen jungen Herrchen zu finden. Kindheitserinnerung der frühen 90er.", "bild_emoji": "🎪", "imdb_rating": 5.8},
{"id": "goodbye-my-lady", "titel": "Leb wohl, Lady", "originaltitel": "Goodbye, My Lady", "jahr": 1956, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Basenji", "stirbt_der_hund": False, "beschreibung": "Ein Junge im Mississippi-Sumpfland findet einen seltsamen lachenden Hund — und muss ihn am Ende zurückgeben. Zeitloser Klassiker.", "bild_emoji": "🌊", "imdb_rating": 7.1},
{"id": "mitt-liv-som-hund", "titel": "Mein Leben als Hund", "originaltitel": "Mitt liv som hund", "jahr": 1985, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Lasse Hallströms schwedisches Meisterwerk: Ein Junge wird aufs Land geschickt und vergleicht sein Leben mit dem Schicksal von Laika.", "bild_emoji": "🇸🇪", "imdb_rating": 7.8},
{"id": "homeward-bound-2", "titel": "Auf dem Weg nach Hause 2 — Im Großstadtdschungel", "originaltitel": "Homeward Bound II: Lost in San Francisco", "jahr": 1996, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Die Tiere aus Teil 1 verirren sich in San Francisco und müssen erneut den Weg nach Hause finden — diesmal durch die Stadt.", "bild_emoji": "🌉", "imdb_rating": 6.0, "streaming": "Disney+"},
{"id": "alpha-2018", "titel": "Alpha", "jahr": 2018, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Wolf", "stirbt_der_hund": False, "beschreibung": "Vor 20.000 Jahren: Ein junger Jäger freundet sich mit einem verletzten Wolf an und legt damit den Grundstein für die Mensch-Hund-Beziehung.", "bild_emoji": "🐺", "imdb_rating": 6.7, "streaming": "Amazon Prime"},
{"id": "nankyoku-monogatari", "titel": "Antarktis", "originaltitel": "Nankyoku Monogatari", "jahr": 1983, "genre": "Drama/Abenteuer", "typ": "film", "hund_rasse": "Sakhalin Husky", "stirbt_der_hund": True, "beschreibung": "Japanisches Meisterwerk: 15 Schlittenhunde werden 1958 in der Antarktis zurückgelassen — die wahre Geschichte zweier Überlebender.", "bild_emoji": "🇯🇵", "imdb_rating": 7.7},
# ── Neue Einträge: Komödie ────────────────────────────────────────
{"id": "cats-and-dogs", "titel": "Cats & Dogs", "jahr": 2001, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle / Verschiedene", "stirbt_der_hund": False, "beschreibung": "Geheimdienstagenten auf vier Pfoten: Hunde gegen Katzen im Kampf um die Weltherrschaft. Spionagefilm-Parodie für die ganze Familie.", "bild_emoji": "🐱", "imdb_rating": 5.2},
{"id": "cats-and-dogs-2", "titel": "Cats & Dogs 2 — Die Rache der Kitty Kahlohr", "originaltitel": "Cats & Dogs: The Revenge of Kitty Galore", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Hunde und Katzen müssen kooperieren, um eine wahnsinnige Superschurkin-Katze zu stoppen.", "bild_emoji": "😾", "imdb_rating": 4.1},
{"id": "shaggy-dog-1959", "titel": "Der zottige Hund", "originaltitel": "The Shaggy Dog", "jahr": 1959, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Alter Ungarischer Hirtenhund (Sheepdogähnlich)", "stirbt_der_hund": False, "beschreibung": "Disney-Familienklassiker: Ein Teenager verwandelt sich durch einen magischen Ring immer wieder in einen Hund. Mit Fred MacMurray.", "bild_emoji": "", "imdb_rating": 6.3},
{"id": "shaggy-dog-2006", "titel": "The Shaggy Dog (2006)", "originaltitel": "The Shaggy Dog", "jahr": 2006, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bobtail (Alter Englischer Schäferhund)", "stirbt_der_hund": False, "beschreibung": "Tim Allen als Staatsanwalt, der sich in einen Hund verwandelt — modernes Remake des Disney-Klassikers mit Slapstick-Humor.", "bild_emoji": "", "imdb_rating": 5.2, "streaming": "Disney+"},
{"id": "marmaduke-2010", "titel": "Marmaduke", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutsche Dogge", "stirbt_der_hund": False, "beschreibung": "Die riesige Comic-Dogge Marmaduke zieht nach Kalifornien und mischt die dortige Hunde-Gesellschaft auf. Lockerleichte Familienkomödie.", "bild_emoji": "😬", "imdb_rating": 4.6},
{"id": "show-dogs", "titel": "Show Dogs", "jahr": 2018, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Rottweiler", "stirbt_der_hund": False, "beschreibung": "Ein Polizei-Rottweiler muss undercover bei einer Hundeschau ermitteln. Kindliche Spionagekomödie mit Will Arnett.", "bild_emoji": "🏅", "imdb_rating": 4.3},
{"id": "must-love-dogs", "titel": "Must Love Dogs", "jahr": 2005, "genre": "Romanze/Komödie", "typ": "film", "hund_rasse": "Neufundländer", "stirbt_der_hund": False, "beschreibung": "Romantische Komödie: Eine frisch Geschiedene sucht online die Liebe — und ein Hund spielt dabei die entscheidende Rolle. Mit Diane Lane.", "bild_emoji": "💕", "imdb_rating": 6.0},
{"id": "best-in-show", "titel": "Best in Show", "jahr": 2000, "genre": "Komödie/Mockumentary", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Christopher Guest's genialer Mockumentary über völlig überdrehte Hundeshow-Besucher beim Mayflower Dog Show — vernichtende Satire auf Hundebesitzer.", "bild_emoji": "🎭", "imdb_rating": 7.8},
{"id": "wiener-dog", "titel": "Wiener-Dog", "jahr": 2016, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Dackel", "stirbt_der_hund": True, "beschreibung": "Todd Solondz' episodischer Indie-Film: Ein kleiner Dackel wandert durch mehrere skurrile Menschenleben. Dunkel, philosophisch, preisgekrönt.", "bild_emoji": "🌭", "imdb_rating": 6.6},
# ── Neue Einträge: Action/Thriller ───────────────────────────────
{"id": "mans-best-friend-1993", "titel": "Man's Best Friend", "originaltitel": "Man's Best Friend", "jahr": 1993, "genre": "Horror/Thriller", "typ": "film", "hund_rasse": "Mastiff", "stirbt_der_hund": True, "beschreibung": "Ein genetisch manipulierter Kettenhund bricht aus einem Labor aus und entpuppt sich als tödliche Bedrohung. B-Movie-Horrorklassiker.", "bild_emoji": "🧬", "imdb_rating": 5.1},
{"id": "white-dog-1982", "titel": "White Dog", "originaltitel": "White Dog", "jahr": 1982, "genre": "Drama/Thriller", "typ": "film", "hund_rasse": "Weißer Schäferhund", "stirbt_der_hund": False, "beschreibung": "Samuel Fullers politisch brisanter Film: Ein weißer Schäferhund wurde auf schwarze Menschen abgerichtet — und soll nun umtrainiert werden.", "bild_emoji": "", "imdb_rating": 7.2},
{"id": "hound-of-baskervilles", "titel": "Der Hund von Baskerville", "originaltitel": "The Hound of the Baskervilles", "jahr": 1939, "genre": "Krimi/Thriller", "typ": "film", "hund_rasse": "Fantastisches Wesen", "stirbt_der_hund": False, "beschreibung": "Basil Rathbone als Sherlock Holmes in der besten Verfilmung des Doyle-Klassikers — die unheilvolle Legende des Dartmoor-Hundes.", "bild_emoji": "🔦", "imdb_rating": 7.4},
# ── Neue Einträge: Japan/International ───────────────────────────
{"id": "mari-to-koinu", "titel": "Mari und ihr Hundewelpe", "originaltitel": "Mari to koinu no monogatari", "jahr": 2007, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Shiba Inu", "stirbt_der_hund": False, "beschreibung": "Wahre Geschichte: Eine Shiba-Inu-Mutter rettet nach einem Erdbeben in den japanischen Alpen ihr Herrchen und ihre Welpen.", "bild_emoji": "🏔️", "imdb_rating": 7.2},
{"id": "ginga-nagareboshi-gin", "titel": "Ginga: Nagareboshi Gin", "originaltitel": "Ginga: Nagareboshi Gin", "jahr": 1986, "genre": "Anime/Abenteuer", "typ": "serie", "hund_rasse": "Akita Inu", "stirbt_der_hund": False, "beschreibung": "Legendärer japanischer Anime: Silber, ein junger Akita, kämpft gegen einen gigantischen Bären — packende Shonen-Klassikerserie der 80er.", "bild_emoji": "", "imdb_rating": 8.0},
# ── Neue Einträge: Serien ──────────────────────────────────────────
{"id": "dog-whisperer", "titel": "Der Hundeflüsterer", "originaltitel": "Dog Whisperer with Cesar Millan", "jahr": 2004, "genre": "Dokumentation/Reality", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Cesar Millan rehabilitiert Problemhunde und schult deren Besitzer. Legendäre Reality-Serie, die das Hundetraining nachhaltig beeinflusst hat.", "bild_emoji": "🤫", "imdb_rating": 7.8},
{"id": "its-me-or-the-dog", "titel": "Ich oder der Hund", "originaltitel": "It's Me or the Dog", "jahr": 2005, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Victoria Stilwell bringt unerzogenen Hunden Manieren bei — mit Positiver Verstärkung statt Dominanztheorie. Britische Erfolgsrealityshow.", "bild_emoji": "💪", "imdb_rating": 7.2},
{"id": "lucky-dog", "titel": "Lucky Dog", "originaltitel": "Lucky Dog", "jahr": 2013, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Trainer Brandon McMillan rettet Todestrakt-Hunde aus Tierheimen und trainiert sie innerhalb einer Woche als perfekte Familienhunde.", "bild_emoji": "🌟", "imdb_rating": 7.5},
{"id": "dog-impossible", "titel": "Dog: Impossible", "originaltitel": "Dog: Impossible", "jahr": 2019, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Matt Beisner arbeitet mit aggressiven und traumatisierten Hunden, die andere aufgegeben haben. National Geographic's bewegende Trainerserie.", "bild_emoji": "🙏", "imdb_rating": 8.1},
{"id": "belle-sebastian-anime", "titel": "Belle und Sebastian (Anime)", "originaltitel": "Belle et Sébastien", "jahr": 1984, "genre": "Anime/Familie", "typ": "serie", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Japanische Zeichentrickserie der NHK: Sébastien und sein riesiger weißer Hund Belle in den Alpen — eine Lieblingsserie mehrerer Generationen.", "bild_emoji": "⛰️", "imdb_rating": 7.5},
{"id": "the-dog-house", "titel": "The Dog House", "originaltitel": "The Dog House", "jahr": 2019, "genre": "Dokumentation", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Wood Green Animal Shelter: Zuschauer beobachten, wie Hunde und Menschen füreinander bestimmt werden. Britische Kultdoku.", "bild_emoji": "🏡", "imdb_rating": 8.2},
{"id": "puppy-dog-pals", "titel": "Puppy Dog Pals", "originaltitel": "Puppy Dog Pals", "jahr": 2017, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Mops", "stirbt_der_hund": False, "beschreibung": "Disney Junior-Serie: Zwei Möpse erleben täglich Abenteuer in der Nachbarschaft, während ihr Herrchen weg ist. Ideal für Kleinkinder.", "bild_emoji": "🐾", "imdb_rating": 7.4, "streaming": "Disney+"},
{"id": "dogs-of-berlin", "titel": "Dogs of Berlin", "jahr": 2018, "genre": "Krimi/Drama", "typ": "serie", "hund_rasse": "Kampfhund", "stirbt_der_hund": False, "beschreibung": "Netflix Deutschland-Originalserie: Zwei Berliner Ermittler aus verschiedenen Welten jagen einen Mörder — düster, social, brutal ehrlich.", "bild_emoji": "🐕", "imdb_rating": 7.4, "streaming": "Netflix"},
# ── Neue Einträge: Dokumentationen ────────────────────────────────
{"id": "the-champions-2015", "titel": "The Champions", "jahr": 2015, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Bewegende Doku über die Pitbulls von Michael Vick's Dogfighting-Ring — und ihre erstaunliche Rehabilitation durch engagierte Retter.", "bild_emoji": "💪", "imdb_rating": 7.8},
{"id": "one-nation-under-dog", "titel": "One Nation Under Dog", "originaltitel": "One Nation Under Dog", "jahr": 2012, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "HBO-Dokumentation über die komplexe Beziehung der Amerikaner zu Hunden — von Tierheimen bis Luxus-Hundesalons.", "bild_emoji": "🇺🇸", "imdb_rating": 7.4},
{"id": "dogs-on-the-inside", "titel": "Dogs on the Inside", "originaltitel": "Dogs on the Inside", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Gefangene in einem Hochsicherheitsgefängnis trainieren Tierheim-Hunde — eine Geschichte über Mitgefühl, Verantwortung und zweite Chancen.", "bild_emoji": "🔒", "imdb_rating": 7.6},
{"id": "wonderdog-2023", "titel": "Wonderdog", "originaltitel": "Wonderdog", "jahr": 2023, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Netflix-Doku über die außergewöhnlichen Fähigkeiten von Hunden: Was können sie wirklich riechen, hören und fühlen? Wissenschaft trifft Herz.", "bild_emoji": "🔬", "imdb_rating": 7.1, "streaming": "Netflix"},
{"id": "the-supervet", "titel": "The Supervet", "originaltitel": "The Supervet", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Noel Fitzpatrick, den visionären Veterinärchirurgen, der unheilbar verletzte Tiere mit Hightech-Prothesen rettet.", "bild_emoji": "🦾", "imdb_rating": 8.7},
{"id": "off-the-chain", "titel": "Off the Chain", "originaltitel": "Off the Chain", "jahr": 2004, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Erschütternde Doku über Pit Bulls in Amerika — zwischen Missbrauch, Dogfighting-Kultur und den Menschen, die für sie kämpfen.", "bild_emoji": "⛓️", "imdb_rating": 7.2},
{"id": "war-dog-soldier", "titel": "War Dog: A Soldier's Best Friend", "originaltitel": "War Dog: A Soldier's Best Friend", "jahr": 2017, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "National Geographic-Doku über Militärhunde und die unzerstörbare Bindung zu ihren Hundeführern — von der Ausbildung bis zum Einsatz.", "bild_emoji": "🎖️", "imdb_rating": 7.8},
]
_SEED_PROMIS = [
@ -176,25 +111,27 @@ _SEED_PROMIS = [
def seed_movies():
"""Füllt die movies-Tabelle mit allen Seed-Einträgen (idempotent per INSERT OR IGNORE)."""
"""Füllt die movies-Tabelle beim ersten Start (idempotent per INSERT OR IGNORE)."""
import logging
logger = logging.getLogger(__name__)
with db() as conn:
for i, f in enumerate(_SEED_FILME):
conn.execute("""
INSERT OR IGNORE INTO movies
(id, titel, originaltitel, jahr, genre, typ, hund_rasse,
stirbt_der_hund, beschreibung, bild_emoji, imdb_rating,
streaming, sort_order)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
f["id"], f["titel"], f.get("originaltitel"),
f.get("jahr"), f.get("genre"), f.get("typ", "film"),
f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0,
f.get("beschreibung"), f.get("bild_emoji", "🐾"),
f.get("imdb_rating"), f.get("streaming"), i,
))
logger.info(f"movies: seed_movies() ausgeführt, {len(_SEED_FILME)} Einträge in der Liste.")
count = conn.execute("SELECT COUNT(*) FROM movies").fetchone()[0]
if count == 0:
for i, f in enumerate(_SEED_FILME):
conn.execute("""
INSERT OR IGNORE INTO movies
(id, titel, originaltitel, jahr, genre, typ, hund_rasse,
stirbt_der_hund, beschreibung, bild_emoji, imdb_rating,
streaming, sort_order)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
f["id"], f["titel"], f.get("originaltitel"),
f.get("jahr"), f.get("genre"), f.get("typ", "film"),
f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0,
f.get("beschreibung"), f.get("bild_emoji", "🐾"),
f.get("imdb_rating"), f.get("streaming"), i,
))
logger.info(f"movies: {len(_SEED_FILME)} Filme geseedet.")
# ------------------------------------------------------------------
@ -243,7 +180,7 @@ _SORT_COLS = {
"jahr_asc": "m.jahr ASC",
"imdb": "m.imdb_rating DESC",
"bewertung": "community_avg DESC",
"default": "CASE WHEN m.imdb_rating IS NULL THEN 1 ELSE 0 END, m.imdb_rating DESC, m.jahr DESC",
"default": "m.sort_order ASC, m.jahr DESC",
}
@router.get("/filme")
@ -386,34 +323,6 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)):
return {"monat": monat, "top": [dict(r) for r in rows], "user_vote": user_vote}
@router.get("/hund-des-monats/kandidaten")
async def get_hdm_kandidaten(user=Depends(get_current_user)):
"""Alle öffentlichen Hunde anderer User, mit aktuellem Stimmenstand."""
monat = datetime.now().strftime("%Y-%m")
with db() as conn:
rows = conn.execute("""
SELECT d.id, d.name, d.rasse, d.foto_url,
u.name AS besitzer_name,
COALESCE(v.stimmen, 0) AS stimmen
FROM dogs d
JOIN users u ON u.id = d.user_id
LEFT JOIN (
SELECT dog_id, COUNT(*) AS stimmen
FROM hund_des_monats_votes
WHERE monat = ?
GROUP BY dog_id
) v ON v.dog_id = d.id
WHERE d.is_public = 1
AND d.user_id != ?
ORDER BY
CASE WHEN d.foto_url IS NOT NULL THEN 0 ELSE 1 END,
stimmen DESC,
d.name ASC
LIMIT 60
""", (monat, user["id"])).fetchall()
return [dict(r) for r in rows]
@router.post("/hund-des-monats/vote")
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
monat = datetime.now().strftime("%Y-%m")
@ -421,9 +330,7 @@ async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_
dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
if dog["user_id"] == user["id"]:
raise HTTPException(403, "Du kannst nicht für deinen eigenen Hund abstimmen.")
if not dog["is_public"]:
if dog["user_id"] != user["id"] and not dog["is_public"]:
raise HTTPException(403, "Dieser Hund ist nicht öffentlich.")
conn.execute("""
INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)

View file

@ -6,7 +6,7 @@ import smtplib
import ssl
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.utils import formataddr, formatdate
from email.utils import formataddr
from datetime import datetime
from typing import List, Optional
@ -87,7 +87,6 @@ def _imap_save_sent(msg_bytes: bytes, account: str):
def _build_message(to: str, subject: str, body: str, account: str, html: str = None) -> MIMEMultipart:
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
msg = MIMEMultipart("alternative")
msg["Date"] = formatdate(localtime=False) # UTC explizit, Container hat keine lokale TZ
msg["Subject"] = subject
msg["From"] = formataddr((acc["name"], acc["from"]))
msg["To"] = to
@ -98,22 +97,11 @@ def _build_message(to: str, subject: str, body: str, account: str, html: str = N
return msg
_LEGAL_FOOTER = (
"\n\n---\n"
"Ban Yaro | René Degelmann | Ringstr. 26, D-85560 Ebersberg\n"
"Web: https://banyaro.app | Mail: partner@banyaro.app\n\n"
"Datenschutzhinweis: Deine Kontaktdaten stammen aus deinem öffentlichen Profil. "
"Verarbeitung auf Basis berechtigten Interesses (Art. 6 Abs. 1 lit. f DSGVO). "
"Datenschutzerklärung: https://banyaro.app/datenschutz\n"
"Widerspruch/Löschung: Einfach auf diese Mail antworten."
)
def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html: str = None):
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
if not acc["user"] or not acc["pass"]:
raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.")
msg = _build_message(to, subject, body + _LEGAL_FOOTER, account, html=html)
msg = _build_message(to, subject, body, account, html=html)
msg_bytes = msg.as_bytes()
ctx = ssl.create_default_context()
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s:
@ -267,7 +255,7 @@ def send_support_mail(to: str, subject: str, body: str):
def outreach_log_endpoint(user=Depends(require_admin)):
with db() as conn:
rows = conn.execute(
"""SELECT ol.id, ol.recipient, ol.subject, ol.body, ol.sent_at,
"""SELECT ol.id, ol.recipient, ol.subject, ol.sent_at,
ol.from_account, u.name AS sent_by_name
FROM outreach_log ol
JOIN users u ON u.id = ol.sent_by

View file

@ -694,12 +694,11 @@ async def list_zuchter_pending(user=Depends(get_current_user)):
raise HTTPException(403, "Nur Moderatoren.")
with db() as conn:
rows = conn.execute(
"""SELECT z.*, u.name AS user_name, m.name AS verified_by_name
"""SELECT z.*, u.name AS user_name
FROM wiki_zuchter z
LEFT JOIN users u ON u.id = z.user_id
LEFT JOIN users m ON m.id = z.verified_by
ORDER BY z.verified ASC, z.created_at ASC
LIMIT 200""",
WHERE z.verified=0
ORDER BY z.created_at ASC""",
).fetchall()
return [dict(r) for r in rows]
@ -717,10 +716,8 @@ async def verify_zuchter(zuchter_id: int, user=Depends(get_current_user)):
).fetchone()
if not row:
raise HTTPException(404, "Züchter nicht gefunden.")
from datetime import datetime
conn.execute(
"UPDATE wiki_zuchter SET verified=1, verified_by=?, verified_at=? WHERE id=?",
(user["id"], datetime.utcnow().isoformat(), zuchter_id)
"UPDATE wiki_zuchter SET verified=1 WHERE id=?", (zuchter_id,)
)
result = conn.execute(
"SELECT * FROM wiki_zuchter WHERE id=?", (zuchter_id,)

View file

@ -100,14 +100,6 @@ def start():
replace_existing=True,
misfire_grace_time=1800,
)
# Täglich 12:00 — Moderation-Overdue-Check
_scheduler.add_job(
_job_moderation_overdue,
CronTrigger(hour=12, minute=0),
id="moderation_overdue",
replace_existing=True,
misfire_grace_time=1800,
)
# 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht
_scheduler.add_job(
_job_quarterly_report,
@ -124,16 +116,8 @@ def start():
replace_existing=True,
misfire_grace_time=3600,
)
# 1. des Monats 00:05 — Hund des Monats Sieger festlegen
_scheduler.add_job(
_job_hdm_winner,
CronTrigger(day=1, hour=0, minute=5),
id="hdm_winner",
replace_existing=True,
misfire_grace_time=3600,
)
_scheduler.start()
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).")
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).")
def stop():
@ -666,115 +650,6 @@ async def _job_ki_health_report():
# ------------------------------------------------------------------
async def _job_moderation_overdue():
"""Sendet Alarm-Mail wenn Moderations-Einträge seit >24h offen sind."""
import os
from mailer import send_email
admin = os.getenv("ADMIN_EMAIL", "")
if not admin:
return
SLA_H = 24
threshold = f"datetime('now', '-{SLA_H} hours')"
overdue = {}
try:
with db() as conn:
n = conn.execute(f"SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing') AND created_at < {threshold}").fetchone()[0]
if n: overdue["Bewerbungen"] = n
n = conn.execute(f"SELECT COUNT(*) FROM users WHERE breeder_status='pending' AND created_at < {threshold}").fetchone()[0]
if n: overdue["Züchter-Anträge"] = n
n = conn.execute(f"SELECT COUNT(*) FROM forum_reports WHERE resolved=0 AND created_at < {threshold}").fetchone()[0]
if n: overdue["Forum-Meldungen"] = n
n = conn.execute(f"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending' AND created_at < {threshold}").fetchone()[0]
if n: overdue["Foto-Einreichungen"] = n
n = conn.execute(f"SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending' AND created_at < {threshold}").fetchone()[0]
if n: overdue["POI-Korrekturen"] = n
n = conn.execute(f"SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0 AND created_at < {threshold}").fetchone()[0]
if n: overdue["Züchter-Einreichungen (Wiki)"] = n
except Exception as e:
logger.error(f"Moderation-Overdue-Check: DB-Fehler: {e}")
return
if not overdue:
logger.info("Moderation-Overdue-Check: Alles im SLA.")
return
now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y %H:%M")
rows_html = "".join(
f'<tr><td style="padding:6px 12px;font-weight:600;color:#c45000">{label}</td>'
f'<td style="padding:6px 12px;font-size:18px;font-weight:800;color:#c45000">{count}</td></tr>'
for label, count in overdue.items()
)
html = f"""\
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
<div style="max-width:560px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
<div style="background:linear-gradient(135deg,#c45000,#e8733a);padding:22px 28px;color:#fff">
<div style="font-size:20px;font-weight:800;margin-bottom:2px"> Moderation überfällig</div>
<div style="opacity:.88;font-size:13px">{now_str} · SLA: {SLA_H}h</div>
</div>
<div style="padding:22px 28px">
<p style="color:#444;margin:0 0 16px">Folgende Einträge warten seit mehr als {SLA_H} Stunden auf Bearbeitung:</p>
<table style="width:100%;border-collapse:collapse;font-size:14px">
<thead><tr style="border-bottom:2px solid #f0e8dc">
<th style="text-align:left;padding:6px 12px;color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.06em">Bereich</th>
<th style="text-align:left;padding:6px 12px;color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.06em">Anzahl</th>
</tr></thead>
<tbody>{rows_html}</tbody>
</table>
<div style="margin-top:20px">
<a href="https://banyaro.app/app/admin" style="display:inline-block;background:#c45000;color:#fff;
text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700;font-size:14px">
Admin-Panel öffnen
</a>
</div>
</div>
<div style="padding:12px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
Ban Yaro · banyaro.app
</div>
</div></body></html>"""
plain = f"Ban Yaro — Moderation überfällig ({now_str})\n\nSeit >{SLA_H}h offen:\n" + \
"\n".join(f"{l}: {c}" for l, c in overdue.items()) + \
"\n\nhttps://banyaro.app/app/admin"
try:
await send_email(admin, f"⚠️ Ban Yaro — Moderation überfällig ({', '.join(overdue)})", html, plain)
logger.info(f"Moderation-Overdue-Mail gesendet: {overdue}")
except Exception as e:
logger.error(f"Moderation-Overdue-Mail fehlgeschlagen: {e}")
def _action_items_html(metrics: dict) -> str:
items = [
("jobs_pending", "Bewerbungen offen"),
("breeder_pending", "Züchter-Anträge"),
("reports_open", "Forum-Meldungen"),
("fotos_pending", "Foto-Einreichungen"),
("poi_edits_pending", "POI-Korrekturen"),
]
open_items = [(label, metrics.get(key, 0)) for key, label in items if metrics.get(key, 0) > 0]
if not open_items:
body = '<span style="color:#16a34a;font-weight:700">✅ Alles erledigt — nichts offen</span>'
else:
pills = "".join(
f'<span style="display:inline-block;background:#fff3e0;color:#c45000;border:1px solid #e8a857;'
f'border-radius:999px;padding:3px 12px;font-size:12px;font-weight:700;margin:2px 4px 2px 0">'
f'{label} <strong style="background:#c45000;color:#fff;border-radius:999px;'
f'padding:0 7px;margin-left:4px">{count}</strong></span>'
for label, count in open_items
)
body = f'<div style="font-size:13px;font-weight:600;color:#c45000;margin-bottom:8px">⚠️ {len(open_items)} Punkt{"e" if len(open_items)!=1 else ""} brauchen deine Aufmerksamkeit</div>{pills}'
link = '<div style="margin-top:10px"><a href="https://banyaro.app/app/admin" style="font-size:12px;color:#C4843A">→ Admin-Panel öffnen</a></div>'
return f'<div style="padding:20px 28px;border-bottom:2px solid #e8a857;background:#fffbf5">' \
f'<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Heute zu erledigen</div>' \
f'{body}{link}</div>'
# JOB: Status-Report per Mail (täglich 06:00 Uhr)
# ------------------------------------------------------------------
async def _job_status_report():
@ -802,7 +677,6 @@ async def _job_status_report():
# Community
metrics["users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
metrics["users_today"] = conn.execute("SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')").fetchone()[0]
metrics["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0]
metrics["poison_active"] = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0").fetchone()[0]
@ -811,28 +685,6 @@ async def _job_status_report():
except Exception:
metrics["lost_active"] = 0
# Action Items
try:
metrics["jobs_pending"] = conn.execute("SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')").fetchone()[0]
except Exception:
metrics["jobs_pending"] = 0
try:
metrics["breeder_pending"] = conn.execute("SELECT COUNT(*) FROM users WHERE breeder_status='pending'").fetchone()[0]
except Exception:
metrics["breeder_pending"] = 0
try:
metrics["reports_open"] = conn.execute("SELECT COUNT(*) FROM forum_reports WHERE resolved=0").fetchone()[0]
except Exception:
metrics["reports_open"] = 0
try:
metrics["fotos_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'").fetchone()[0]
except Exception:
metrics["fotos_pending"] = 0
try:
metrics["poi_edits_pending"] = conn.execute("SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'").fetchone()[0]
except Exception:
metrics["poi_edits_pending"] = 0
# Wiki-Interesse
try:
metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0]
@ -884,9 +736,6 @@ async def _job_status_report():
<div style="opacity:.88;font-size:13px">{now_str} Uhr</div>
</div>
<!-- Action Items -->
{_action_items_html(metrics)}
<!-- Scheduler-Status -->
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Scheduler-Jobs</div>
@ -900,14 +749,14 @@ async def _job_status_report():
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Community</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
{"".join(f'<div style="background:#fdf6ef;border-radius:8px;padding:10px 14px"><div style="font-size:20px;font-weight:800;color:#C4843A">{v}</div><div style="font-size:11px;color:#888">{k}</div></div>' for k,v in [
("Nutzer gesamt",metrics["users"]),
("Neue Nutzer heute",metrics["users_today"]),
("Nutzer",metrics["users"]),
("Hunde",metrics["dogs"]),
("Tagebuch-Einträge",metrics["diary_entries"]),
("Aktive Giftköder",metrics["poison_active"]),
("Vermisste Hunde",metrics["lost_active"]),
("'So einen hab ich'",metrics["interesse_hat"]),
("'Interessiert mich'",metrics["interesse_will"]),
("Züchter (pending)",metrics["zuchter_pending"]),
])}
</div>
</div>
@ -921,28 +770,19 @@ async def _job_status_report():
</body>
</html>"""
action_open = [l for k,l in [
("jobs_pending","Bewerbungen"),("breeder_pending","Züchter-Anträge"),
("reports_open","Meldungen"),("fotos_pending","Fotos"),("poi_edits_pending","POI-Korrekturen"),
] if metrics.get(k,0) > 0]
plain = f"""Ban Yaro Status-Report — {now_str}
=== HEUTE ZU ERLEDIGEN ===
{"✅ Alles erledigt" if not action_open else "⚠️ " + ", ".join(f"{l} ({metrics[k]})" for k,l in [
("jobs_pending","Bewerbungen"),("breeder_pending","Züchter-Anträge"),
("reports_open","Meldungen"),("fotos_pending","Fotos"),("poi_edits_pending","POI-Korrekturen"),
] if metrics.get(k,0) > 0)}
=== Scheduler-Jobs ===
{job_rows_txt}
=== Community ===
Nutzer gesamt: {metrics['users']} (+{metrics['users_today']} heute)
Nutzer: {metrics['users']}
Hunde: {metrics['dogs']}
Tagebuch-Einträge: {metrics['diary_entries']}
Aktive Giftköder: {metrics['poison_active']}
Vermisste Hunde: {metrics['lost_active']}
'So einen hab ich': {metrics['interesse_hat']}
'Interessiert mich': {metrics['interesse_will']}
Züchter (pending): {metrics['zuchter_pending']}
"""
try:
@ -1118,57 +958,3 @@ def _compute_milestone(today: date, bday: date, dog_name: str):
return titel, text
return None
# ------------------------------------------------------------------
# JOB: Hund des Monats — Sieger des Vormonats festlegen
# ------------------------------------------------------------------
async def _job_hdm_winner():
"""Läuft am 1. des Monats 00:05 und schreibt den Sieger des Vormonats."""
today = datetime.now(tz=_TZ)
# Vormonat berechnen
first_this = today.replace(day=1)
last_month = (first_this - timedelta(days=1)).replace(day=1)
monat = last_month.strftime("%Y-%m")
with db() as conn:
# Schon eingetragen?
existing = conn.execute(
"SELECT id FROM hund_des_monats_wins WHERE monat=?", (monat,)
).fetchone()
if existing:
logger.info(f"HdM-Winner {monat}: bereits eingetragen, übersprungen.")
_log_job("hdm_winner", "ok", f"bereits vorhanden für {monat}")
return
winner = conn.execute("""
SELECT v.dog_id, d.name, d.user_id, COUNT(v.id) AS stimmen
FROM hund_des_monats_votes v
JOIN dogs d ON d.id = v.dog_id
WHERE v.monat = ?
GROUP BY v.dog_id
ORDER BY stimmen DESC
LIMIT 1
""", (monat,)).fetchone()
if not winner:
logger.info(f"HdM-Winner {monat}: keine Stimmen, kein Sieger.")
_log_job("hdm_winner", "ok", f"keine Stimmen für {monat}")
return
conn.execute(
"INSERT OR IGNORE INTO hund_des_monats_wins (dog_id, monat, stimmen) VALUES (?, ?, ?)",
(winner["dog_id"], monat, winner["stimmen"]),
)
month_label = last_month.strftime("%B %Y")
send_push_to_user(winner["user_id"], {
"type": "hdm_winner",
"title": f"🏆 {winner['name']} ist Hund des Monats!",
"body": f"{winner['name']} hat den {month_label} gewonnen — herzlichen Glückwunsch!",
"data": {"page": "forum"},
"tag": f"hdm-{monat}",
})
logger.info(f"HdM-Winner {monat}: Hund {winner['dog_id']} ('{winner['name']}', {winner['stimmen']} Stimmen) eingetragen.")
_log_job("hdm_winner", "ok", f"{monat}: {winner['name']} ({winner['stimmen']} Stimmen)")

View file

@ -435,7 +435,7 @@
.form-label {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--c-text);
color: var(--c-text-secondary);
}
/* iOS Safari: font-size < 16px triggert Auto-Zoom beim Fokus — muss alle Klassen überschreiben */
@ -4179,19 +4179,6 @@ html.modal-open {
text-overflow: ellipsis;
max-width: 10rem; /* prevents single pill from being wider than ~160px on mobile */
}
.by-tab-text {
display: inline-block;
white-space: nowrap;
transition: transform 0.3s ease;
}
.by-tab-text.scrolling {
animation: forum-tab-scroll 1.8s ease-in-out 0.3s infinite alternate;
transition: none;
}
@keyframes forum-tab-scroll {
from { transform: translateX(0); }
to { transform: translateX(var(--tab-scroll-px, 0)); }
}
/* Category badge (colored pill) */
.forum-category-badge {
@ -4213,59 +4200,6 @@ html.modal-open {
.forum-category-badge--tauschboerse { background: #fce7f3; color: #9d174d; }
/* Search */
/* Hund des Monats — kompakte Forum-Kachel */
.forum-hdm-tile {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: linear-gradient(135deg, var(--c-surface-2) 0%, var(--c-bg) 100%);
border: 1.5px solid var(--c-border-light);
border-radius: var(--radius-lg);
cursor: pointer;
margin-bottom: var(--space-3);
min-width: 0;
transition: border-color .15s, box-shadow .15s;
}
.forum-hdm-tile:hover { border-color: var(--c-primary); box-shadow: var(--shadow-sm); }
.forum-hdm-tile-trophy { font-size: 1.5rem; flex-shrink: 0; }
.forum-hdm-tile-body {
flex: 1;
min-width: 0;
}
.forum-hdm-tile-title {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: var(--c-text-muted);
text-transform: uppercase;
letter-spacing: .04em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.forum-hdm-tile-winner {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--c-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.forum-hdm-tile-meta {
font-size: var(--text-xs);
color: var(--c-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.forum-hdm-tile-cta {
flex-shrink: 0;
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: var(--c-primary);
white-space: nowrap;
}
.forum-search-wrap {
position: relative;
}
@ -4984,25 +4918,6 @@ html.modal-open {
}
/* Filter-Row */
.movies-search-row {
position: relative;
padding: var(--space-3) 0 var(--space-1);
}
.movies-search-icon {
position: absolute;
left: var(--space-3);
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--c-text-muted);
pointer-events: none;
}
.movies-search-input {
padding-left: calc(var(--space-3) + 16px + var(--space-2)) !important;
font-size: var(--text-sm);
}
.movies-filter-row {
display: flex;
gap: var(--space-2);
@ -5241,19 +5156,11 @@ html.modal-open {
margin-bottom: var(--space-3);
}
/* Kandidaten-Suche */
.hdm-kandidaten-search {
margin-bottom: var(--space-3);
}
/* Vote-Grid */
.hdm-vote-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
max-height: 340px;
overflow-y: auto;
padding-right: var(--space-1);
}
.hdm-vote-card {
@ -6065,21 +5972,6 @@ html.modal-open {
cursor: pointer;
}
/* Hund des Monats — Profil-Badge */
.dp-hdm-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #78350f;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
letter-spacing: .02em;
box-shadow: 0 1px 3px rgba(0,0,0,.15);
}
/* --- Foto-Editor Modal --- */
.photo-editor { display: flex; flex-direction: column; gap: var(--space-3); align-items: center; }
.photo-editor-preview {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 KiB

View file

@ -90,7 +90,7 @@
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=545">
<link rel="stylesheet" href="/css/layout.css?v=545">
<link rel="stylesheet" href="/css/components.css?v=546">
<link rel="stylesheet" href="/css/components.css?v=545">
</head>
<body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '597'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '583'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';

View file

@ -48,9 +48,6 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------
function _render() {
_container.innerHTML = `
<!-- Action Items -->
<div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div>
<!-- Tabs -->
<div class="by-tabs adm-tabs" id="adm-tabs">
${TABS.map(t => `
@ -76,68 +73,9 @@ window.Page_admin = (() => {
});
});
_renderActionItems();
_renderTab();
}
async function _renderActionItems() {
const el = _container.querySelector('#adm-action-items');
if (!el) return;
let d;
try { d = await API.get('/admin/action-items'); } catch { return; }
const items = [
{ key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' },
{ key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' },
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' },
];
const open = items.filter(i => d[i.key] > 0);
const usersToday = d.users_today || 0;
el.innerHTML = `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
${UI.icon('check-square')} Zu erledigen
</span>
${open.length === 0
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
${UI.icon('check-circle')} Alles erledigt
</span>`
: open.map(i => `
<button data-action-tab="${i.tab}"
style="display:inline-flex;align-items:center;gap:4px;
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
border:1px solid var(--c-warning,#e65100);border-radius:999px;
padding:2px 10px;font-size:var(--text-xs);font-weight:700;cursor:pointer">
${UI.icon(i.icon)} ${i.label}
<span style="background:var(--c-warning,#e65100);color:#fff;
border-radius:999px;padding:0 6px;margin-left:2px">
${d[i.key]}
</span>
</button>`).join('')
}
<span style="margin-left:auto;font-size:var(--text-xs);color:var(--c-text-muted)">
${UI.icon('user-plus')} ${usersToday} neue Nutzer heute
</span>
</div>`;
el.querySelectorAll('[data-action-tab]').forEach(btn => {
btn.addEventListener('click', () => {
_tab = btn.dataset.actionTab;
_container.querySelectorAll('#adm-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === _tab)
);
_renderTab();
});
});
}
async function _renderTab() {
const el = _container.querySelector('#adm-content');
if (!el) return;
@ -1460,43 +1398,6 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------
// TAB: MODERATION
// ------------------------------------------------------------------
function _ageLabel(createdAt) {
if (!createdAt) return '';
const h = (Date.now() - new Date(createdAt + 'Z').getTime()) / 3600000;
const overdue = h >= 24;
const label = h < 1 ? '<1h' : h < 24 ? `${Math.floor(h)}h` : `${Math.floor(h/24)}d ${Math.floor(h%24)}h`;
return `<span style="font-size:var(--text-xs);font-weight:700;padding:1px 7px;border-radius:999px;
margin-left:6px;${overdue
? 'background:#fef2f2;color:#dc2626;border:1px solid #fca5a5'
: 'background:var(--c-surface-2);color:var(--c-text-muted);border:1px solid var(--c-border)'}">
${overdue ? '⚠️ ' : ''}${label}
</span>`;
}
function _historySection(label, items, renderItem) {
const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`;
return `
<details style="margin-bottom:var(--space-4)">
<summary style="cursor:pointer;list-style:none;display:flex;align-items:center;gap:var(--space-2);
font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.06em;padding:var(--space-2) 0;
border-top:1px solid var(--c-border)">
${UI.icon('clock-countdown')} ${items.length} erledigte ${label}
<svg class="ph-icon" style="margin-left:auto;transition:transform .2s" aria-hidden="true">
<use href="/icons/phosphor.svg#caret-down"></use>
</svg>
</summary>
<div style="margin-top:var(--space-2);display:flex;flex-direction:column;gap:var(--space-1)">
${items.map(item => `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-sm);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
${renderItem(item)}
</div>`).join('')}
</div>
</details>`;
}
async function _renderModeration(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
@ -1511,52 +1412,12 @@ window.Page_admin = (() => {
async function _loadModeration(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const [zuchter, fotos, reports, poiEdits] = await Promise.all([
const [zuchter, fotos] = await Promise.all([
API.get('/wiki/zuchter/pending').catch(() => []),
API.get('/wiki/foto-submissions').catch(() => []),
API.get('/moderation/reports').catch(() => []),
API.get('/moderation/poi-edits').catch(() => []),
]);
const zuchterPending = zuchter.filter(z => !z.verified);
const zuchterDone = zuchter.filter(z => z.verified);
const fotosPending = fotos.filter(f => f.status === 'pending');
const fotosDone = fotos.filter(f => f.status !== 'pending');
const reportsPending = reports.filter(r => !r.resolved);
const reportsDone = reports.filter(r => r.resolved);
const poiPending = poiEdits.filter(e => e.status === 'pending');
const poiDone = poiEdits.filter(e => e.status !== 'pending');
const modItems = [
{ label: 'Züchter-Einreichungen', count: zuchterPending.length, icon: 'certificate' },
{ label: 'Foto-Einreichungen', count: fotosPending.length, icon: 'image' },
{ label: 'Forum-Meldungen', count: reportsPending.length, icon: 'warning' },
{ label: 'POI-Korrekturen', count: poiPending.length, icon: 'map-pin' },
].filter(i => i.count > 0);
let html = `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
background:var(--c-surface);border:1px solid var(--c-border);
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4);
margin-bottom:var(--space-4)">
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
${UI.icon('check-square')} Zu erledigen
</span>
${modItems.length === 0
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
${UI.icon('check-circle')} Alles erledigt
</span>`
: modItems.map(i => `
<span style="display:inline-flex;align-items:center;gap:4px;
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
border:1px solid var(--c-warning,#e65100);border-radius:999px;
padding:2px 10px;font-size:var(--text-xs);font-weight:700">
${UI.icon(i.icon)} ${i.label}
<strong style="background:var(--c-warning,#e65100);color:#fff;
border-radius:999px;padding:0 6px;margin-left:2px">${i.count}</strong>
</span>`).join('')
}
</div>`;
let html = '';
// --- Züchter-Einreichungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
@ -1564,24 +1425,23 @@ window.Page_admin = (() => {
margin-bottom:var(--space-3)">
Züchter-Einreichungen
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchterPending.length}</span>
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchter.length}</span>
</h3>`;
if (!zuchterPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Einreichungen.</p>`;
if (!zuchter.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-6)">Keine ausstehenden Einreichungen.</p>`;
} else {
html += `<div class="card adm-table-card" style="margin-bottom:var(--space-3)"><div class="adm-table-scroll"><table class="adm-table">
html += `<div class="card adm-table-card" style="margin-bottom:var(--space-6)"><div class="adm-table-scroll"><table class="adm-table">
<thead><tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Rasse</th><th class="adm-th">Name / Zwingername</th>
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Alter</th><th class="adm-th">Website</th><th class="adm-th"></th>
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Website</th><th class="adm-th"></th>
</tr></thead><tbody>
${zuchterPending.map((z, i) => `
${zuchter.map((z, i) => `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
<td class="adm-td" style="font-weight:var(--weight-semibold)">${_esc(z.rasse_slug)}</td>
<td class="adm-td">${_esc(z.name)}${z.zwingername ? `<br><span style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(z.zwingername)}</span>` : ''}</td>
<td class="adm-td">${_esc([z.plz, z.ort, z.bundesland].filter(Boolean).join(' '))}</td>
<td class="adm-td">${z.vdh_mitglied ? `<span style="color:var(--c-success);display:flex;align-items:center;gap:2px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> VDH</span>` : '—'}</td>
<td class="adm-td">${_ageLabel(z.created_at)}</td>
<td class="adm-td">${z.website ? `<a href="${_esc(z.website)}" target="_blank" style="color:var(--c-primary);font-size:var(--text-xs)">Link</a>` : '—'}</td>
<td class="adm-td" style="text-align:right;white-space:nowrap">
<button class="btn btn-sm btn-primary adm-zuchter-approve" data-id="${z.id}" style="margin-right:4px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button>
@ -1590,10 +1450,6 @@ window.Page_admin = (() => {
</tr>`).join('')}
</tbody></table></div></div>`;
}
// Züchter-History
if (zuchterDone.length) html += _historySection('Züchter-Einreichungen', zuchterDone,
z => `<span style="font-weight:600">${_esc(z.name)}</span> · ${_esc(z.rasse_slug)} ·
${UI.icon('check-circle')} ${_esc(z.verified_by_name||'?')} · ${(z.verified_at||'').slice(0,10)}`);
// --- Wiki-Foto-Einreichungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
@ -1601,20 +1457,19 @@ window.Page_admin = (() => {
margin-bottom:var(--space-3)">
Wiki-Foto-Einreichungen
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotosPending.length}</span>
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotos.length}</span>
</h3>`;
if (!fotosPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Foto-Einreichungen.</p>`;
if (!fotos.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine ausstehenden Foto-Einreichungen.</p>`;
} else {
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4);margin-bottom:var(--space-3)">
${fotosPending.map(f => `
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4)">
${fotos.map(f => `
<div class="card" style="padding:var(--space-4)">
<img src="${_esc(f.foto_url)}" alt=""
style="width:100%;height:140px;object-fit:cover;border-radius:var(--radius-md);margin-bottom:var(--space-3)">
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">${_esc(f.rasse_name)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">von ${_esc(f.user_name)}</div>
<div style="margin-bottom:var(--space-3)">${_ageLabel(f.created_at)}</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-3)">von ${_esc(f.user_name)}</div>
${f.aktuell_foto ? `<img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm);
opacity:.5;margin-bottom:var(--space-2)">
@ -1627,111 +1482,6 @@ window.Page_admin = (() => {
</div>`;
}
// Fotos-History
if (fotosDone.length) html += _historySection('Foto-Einreichungen', fotosDone,
f => `<img src="${_esc(f.foto_url)}" style="width:32px;height:32px;object-fit:cover;border-radius:4px;vertical-align:middle;margin-right:6px">
<span style="font-weight:600">${_esc(f.rasse_name||'?')}</span> · von ${_esc(f.user_name||'?')} ·
${f.status==='approved' ? `${UI.icon('check-circle')} genehmigt` : `${UI.icon('x-circle')} abgelehnt`}
${f.reviewed_by_name ? ` von ${_esc(f.reviewed_by_name)}` : ''} · ${(f.reviewed_at||'').slice(0,10)}`);
// --- Forum-Meldungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
margin:var(--space-4) 0 var(--space-3)">
Forum-Meldungen
<span style="background:${reportsPending.length ? 'var(--c-danger)' : 'var(--c-primary)'};color:#fff;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
${reportsPending.length}
</span>
</h3>`;
if (!reportsPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine offenen Meldungen.</p>`;
} else {
html += `<div style="display:flex;flex-direction:column;gap:var(--space-3);margin-bottom:var(--space-3)">
${reportsPending.map(r => `
<div class="card" style="padding:var(--space-4);border-left:3px solid var(--c-danger)">
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
${_esc(r.target_type)} #${r.target_id} · Gemeldet von <strong>${_esc(r.melder_name || '?')}</strong>
${_ageLabel(r.created_at)}
</div>
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);margin-bottom:var(--space-1)">
Grund: ${_esc(r.grund)}
</div>
${r.content_preview ? `
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-sm)">${_esc(r.content_preview)}</div>` : ''}
</div>
<button class="btn btn-sm btn-primary adm-mod-resolve" data-rid="${r.id}" title="Als erledigt markieren">
${UI.icon('check')}
</button>
</div>
</div>`).join('')}
</div>`;
}
// Meldungen-History
if (reportsDone.length) html += _historySection('Forum-Meldungen', reportsDone,
r => `${_esc(r.target_type)} #${r.target_id} · ${_esc(r.grund)} · Gemeldet von ${_esc(r.melder_name||'?')} ·
${UI.icon('check-circle')} ${_esc(r.resolved_by_name||'?')} · ${(r.resolved_at||'').slice(0,10)}`);
// --- POI-Korrekturen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
margin:var(--space-2) 0 var(--space-3)">
POI-Korrekturen
<span style="background:${poiPending.length ? 'var(--c-warning,#e65100)' : 'var(--c-primary)'};color:#fff;
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
${poiPending.length}
</span>
</h3>`;
if (!poiPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine ausstehenden POI-Korrekturen.</p>`;
} else {
html += `<div class="card adm-table-card"><div class="adm-table-scroll">
<table class="adm-table">
<thead><tr style="background:var(--c-surface-2);text-align:left">
<th class="adm-th">Ort</th>
<th class="adm-th">Feld</th>
<th class="adm-th">Alt</th>
<th class="adm-th">Neu</th>
<th class="adm-th">Von</th>
<th class="adm-th">Alter</th>
<th class="adm-th"></th>
</tr></thead>
<tbody>
${poiPending.map((e, i) => `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
<td class="adm-td" style="font-weight:var(--weight-semibold)">${_esc(e.poi_name || `OSM #${e.osm_id}`)}</td>
<td class="adm-td"><code style="font-size:var(--text-xs)">${_esc(e.field)}</code></td>
<td class="adm-td" style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(e.old_value || '—')}</td>
<td class="adm-td" style="font-size:var(--text-xs)">${_esc(e.new_value || '—')}</td>
<td class="adm-td" style="color:var(--c-text-muted)">${_esc(e.einreicher_name || '?')}</td>
<td class="adm-td">${_ageLabel(e.created_at)}</td>
<td class="adm-td" style="text-align:right;white-space:nowrap">
<button class="btn btn-sm btn-primary adm-poi-approve" data-id="${e.id}" style="margin-right:4px">
${UI.icon('check')}
</button>
<button class="btn btn-sm btn-ghost adm-poi-reject" data-id="${e.id}" style="color:var(--c-danger)">
${UI.icon('x')}
</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div></div>`;
}
// POI-History
if (poiDone.length) html += _historySection('POI-Korrekturen', poiDone,
e => `<span style="font-weight:600">${_esc(e.poi_name||`OSM #${e.osm_id}`)}</span> ·
<code style="font-size:var(--text-xs)">${_esc(e.field)}</code>:
<span style="text-decoration:line-through;color:var(--c-text-muted)">${_esc(e.old_value||'—')}</span>
${_esc(e.new_value||'—')} ·
${e.status==='approved' ? `${UI.icon('check-circle')} freigegeben` : `${UI.icon('x-circle')} abgelehnt`}
${e.mod_name ? ` von ${_esc(e.mod_name)}` : ''} · ${(e.resolved_at||'').slice(0,10)}`);
el.innerHTML = html;
// Züchter freigeben
@ -1770,41 +1520,6 @@ window.Page_admin = (() => {
await _loadModeration(el);
});
});
// Forum-Meldung erledigen
el.querySelectorAll('.adm-mod-resolve').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/reports/${btn.dataset.rid}`, {});
await _loadModeration(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
// POI-Korrektur freigeben
el.querySelectorAll('.adm-poi-approve').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'approve' });
UI.toast.success('Korrektur übernommen.');
await _loadModeration(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
// POI-Korrektur ablehnen
el.querySelectorAll('.adm-poi-reject').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'reject' });
UI.toast.success('Korrektur abgelehnt.');
await _loadModeration(el);
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
});
});
}
// ------------------------------------------------------------------
@ -2419,10 +2134,8 @@ window.Page_admin = (() => {
</tr>
</thead>
<tbody>
${log.map((l, i) => `
<tr data-log-idx="${i}" style="border-bottom:1px solid var(--c-border);cursor:pointer"
onmouseover="this.style.background='var(--c-surface-2)'"
onmouseout="this.style.background=''">
${log.map(l => `
<tr style="border-bottom:1px solid var(--c-border)">
<td style="padding:var(--space-2)">${accountBadge(l.from_account)}</td>
<td style="padding:var(--space-2)">${_esc(l.recipient)}</td>
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td>
@ -2436,28 +2149,6 @@ window.Page_admin = (() => {
</div>
`;
// Log-Zeile: Mail-Inhalt anzeigen
el.querySelectorAll('tr[data-log-idx]').forEach(row => {
row.addEventListener('click', () => {
const l = log[Number(row.dataset.logIdx)];
if (!l) return;
UI.modal.open({
title: _esc(l.subject),
body: `
<div style="margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--c-text-muted)">
<strong>An:</strong> ${_esc(l.recipient)} &nbsp;·&nbsp;
<strong>Von:</strong> ${_esc(l.from_account)}@banyaro.app &nbsp;·&nbsp;
${(l.sent_at||'').slice(0,16).replace('T',' ')}
</div>
<pre style="white-space:pre-wrap;font-family:inherit;font-size:var(--text-sm);
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);max-height:60vh;overflow-y:auto;
color:var(--c-text)">${_esc(l.body || '(kein Text gespeichert)')}</pre>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
});
});
// Vorlage in Compose laden
function _loadTplIntoCompose(id) {
const tpl = templates.find(t => t.id === id);

View file

@ -97,19 +97,8 @@ window.Page_dog_profile = (() => {
<h2 style="font-size:var(--text-2xl);font-weight:700;
color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2>
${dog.rasse
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${_esc(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-2)"></p>`}
${(dog.hdm_wins?.length) ? `
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);justify-content:center;margin-bottom:var(--space-5)">
${dog.hdm_wins.map(m => {
const [y, mo] = m.split('-');
const label = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
.format(new Date(+y, +mo - 1, 1));
return `<span class="dp-hdm-badge" title="Hund des Monats ${label}">🏆 ${label}</span>`;
}).join('')}
</div>
` : `<div style="margin-bottom:var(--space-5)"></div>`}
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-5)">${_esc(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-5)"></p>`}
<!-- Info-Grid -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);

View file

@ -66,7 +66,6 @@ window.Page_forum = (() => {
_container = container;
_appState = appState;
_render();
_loadHdmCard();
_loadThreads(true);
}
@ -99,17 +98,15 @@ window.Page_forum = (() => {
<div class="forum-category-tabs by-tabs" id="forum-tabs">
${KATEGORIEN.map(k => `
<button class="by-tab ${k.key === _aktivKat ? 'active' : ''}"
data-kat="${k.key}"><span class="by-tab-text">${_esc(k.label)}</span></button>
data-kat="${k.key}">${_esc(k.label)}</button>
`).join('')}
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
data-section="map"><span class="by-tab-text">${UI.icon('users')} Mitgliederkarte</span></button>
data-section="map">${UI.icon('users')} Mitgliederkarte</button>
</div>
<!-- Rechte Spalte: HdM-Kachel + Suche + Threads -->
<!-- Rechte Spalte: Suche + Threads -->
<div class="forum-main-col">
<div id="forum-hdm-card"></div>
<div class="forum-search-wrap">
<input type="search" class="forum-search" id="forum-search"
placeholder="Forum durchsuchen…" autocomplete="off">
@ -130,23 +127,6 @@ window.Page_forum = (() => {
const _tabCount = _tabsEl.querySelectorAll('.by-tab').length;
_tabsEl.style.setProperty('--forum-tab-cols', Math.ceil(_tabCount / 2));
// Marquee-Scroll: nur Tabs animieren, bei denen Text wirklich abgeschnitten ist
_tabsEl.addEventListener('mouseenter', e => {
const btn = e.target.closest('.by-tab');
const span = btn?.querySelector('.by-tab-text');
if (!span) return;
const style = getComputedStyle(btn);
const padH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
const overflow = span.scrollWidth - (btn.clientWidth - padH);
if (overflow <= 2) return;
span.style.setProperty('--tab-scroll-px', `-${overflow}px`);
span.classList.add('scrolling');
}, true);
_tabsEl.addEventListener('mouseleave', e => {
const span = e.target.closest('.by-tab')?.querySelector('.by-tab-text');
if (span) span.classList.remove('scrolling');
}, true);
// Tab-Klicks
_tabsEl.addEventListener('click', e => {
const btn = e.target.closest('[data-kat], [data-section]');
@ -195,177 +175,6 @@ window.Page_forum = (() => {
document.getElementById('forum-rules-btn').addEventListener('click', _showRules);
}
// ----------------------------------------------------------
// Hund des Monats — Kachel + Modal
// ----------------------------------------------------------
async function _loadHdmCard() {
const card = document.getElementById('forum-hdm-card');
if (!card) return;
try {
const data = await API.get('/movies/hund-des-monats');
const [year, month] = data.monat.split('-');
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long' })
.format(new Date(+year, +month - 1, 1));
const top = data.top?.[0];
const winnerLine = top
? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}`
: 'Noch keine Stimmen';
const metaLine = top
? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}`
: 'Sei der Erste!';
card.innerHTML = `
<div class="forum-hdm-tile" id="forum-hdm-tile">
<div class="forum-hdm-tile-trophy">🏆</div>
<div class="forum-hdm-tile-body">
<div class="forum-hdm-tile-title">Hund des Monats · ${_esc(monthName)}</div>
<div class="forum-hdm-tile-winner">${winnerLine}</div>
<div class="forum-hdm-tile-meta">${metaLine}</div>
</div>
<div class="forum-hdm-tile-cta">${UI.icon('arrow-right')}</div>
</div>`;
document.getElementById('forum-hdm-tile')?.addEventListener('click', () => _openHdmModal(data));
} catch {
// Kachel bleibt leer bei Fehler
}
}
async function _openHdmModal(data) {
try { data = await API.get('/movies/hund-des-monats'); } catch { /* gecachte Daten */ }
const [year, month] = data.monat.split('-');
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
.format(new Date(+year, +month - 1, 1));
const topList = data.top?.length
? data.top.slice(0, 5).map((dog, i) => {
const medal = ['🥇','🥈','🥉','4⃣','5⃣'][i];
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-top-entry">
<span class="hdm-top-medal">${medal}</span>
<div class="hdm-top-av">${av}</div>
<div class="hdm-top-info">
<div class="hdm-top-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
</div>
<div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
</div>`;
}).join('')
: `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Stimmen. Sei der Erste!</p>`;
const voteHint = !_appState.user
? `<div class="hdm-section">
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
<a href="#" id="hdm-login-link" style="color:var(--c-primary);font-weight:var(--weight-semibold)">Anmelden</a>
um abstimmen zu können.
</p>
</div>`
: `<div class="hdm-section">
<h3 class="hdm-section-title">Für welchen Hund möchtest du abstimmen?</h3>
<div class="hdm-kandidaten-search">
<input type="search" id="hdm-search" class="form-control"
placeholder="Name oder Rasse suchen …" autocomplete="off"
style="font-size:var(--text-sm)">
</div>
<div id="hdm-kandidaten-grid" class="hdm-vote-grid">
${UI.skeleton(3)}
</div>
</div>`;
const body = `
<div class="hdm-header">
<div class="hdm-trophy">🏆</div>
<h2 class="hdm-title">Hund des Monats</h2>
<div class="hdm-monat">${_esc(monthName)}</div>
</div>
${voteHint}
<div class="hdm-section">
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
<div id="hdm-top-list">${topList}</div>
</div>`;
UI.modal.open({ title: '🏆 Hund des Monats', body,
footer: `<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Schließen</button>` });
document.getElementById('hdm-login-link')?.addEventListener('click', e => {
e.preventDefault(); UI.modal.close(); App.navigate('settings');
});
if (!_appState.user) return;
// Kandidaten laden und rendern
let _kandidaten = [];
const _renderKandidaten = (list) => {
const grid = document.getElementById('hdm-kandidaten-grid');
if (!grid) return;
if (!list.length) {
grid.innerHTML = `<p style="color:var(--c-text-secondary);font-size:var(--text-sm);padding:var(--space-3) 0">Keine Hunde gefunden.</p>`;
return;
}
grid.innerHTML = list.map(dog => {
const isVoted = data.user_vote === dog.id;
const av = dog.foto_url
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
return `
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}">
<div class="hdm-vote-av">${av}</div>
<div class="hdm-vote-name">${_esc(dog.name)}</div>
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
${vorname ? `<div class="hdm-vote-besitzer" style="font-size:var(--text-xs);color:var(--c-text-muted)">von ${vorname}</div>` : ''}
${dog.stimmen > 0 ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${dog.stimmen} ${UI.icon('star')}</div>` : ''}
<button class="btn btn-sm ${isVoted ? 'btn-primary' : 'btn-secondary'} hdm-vote-btn"
data-dog-id="${dog.id}" ${isVoted ? 'disabled' : ''}>
${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'}
</button>
</div>`;
}).join('');
grid.querySelectorAll('.hdm-vote-btn:not([disabled])').forEach(btn => {
btn.addEventListener('click', async () => {
const dogId = parseInt(btn.dataset.dogId);
await UI.asyncButton(btn, async () => {
try {
await API.post('/movies/hund-des-monats/vote', { dog_id: dogId });
data.user_vote = dogId;
UI.toast.success('Stimme abgegeben!');
UI.modal.close();
_loadHdmCard();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
}
});
});
});
};
try {
_kandidaten = await API.get('/movies/hund-des-monats/kandidaten');
} catch {
document.getElementById('hdm-kandidaten-grid').innerHTML =
`<p style="color:var(--c-danger);font-size:var(--text-sm)">Kandidaten konnten nicht geladen werden.</p>`;
return;
}
_renderKandidaten(_kandidaten);
document.getElementById('hdm-search')?.addEventListener('input', e => {
const q = e.target.value.trim().toLowerCase();
_renderKandidaten(q
? _kandidaten.filter(d =>
(d.name || '').toLowerCase().includes(q) ||
(d.rasse || '').toLowerCase().includes(q))
: _kandidaten
);
});
}
// ----------------------------------------------------------
// Threads laden
// ----------------------------------------------------------

View file

@ -15,7 +15,6 @@ window.Page_movies = (() => {
let _filter = 'alle';
let _typ = 'alle'; // alle | film | serie | doku
let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung
let _search = '';
// ----------------------------------------------------------
// INIT
@ -42,6 +41,7 @@ window.Page_movies = (() => {
<div class="movies-tabs">
<button class="movies-tab${_activeTab === 'filme' ? ' movies-tab--active' : ''}" data-tab="filme">${UI.icon('film-slate')} Filme</button>
<button class="movies-tab${_activeTab === 'promis' ? ' movies-tab--active' : ''}" data-tab="promis">${UI.icon('star')} Berühmtheiten</button>
<button class="movies-tab${_activeTab === 'hdm' ? ' movies-tab--active' : ''}" data-tab="hdm">${UI.icon('paw-print')} Hund des Monats</button>
</div>
<div id="movies-tab-content"></div>
`;
@ -66,6 +66,7 @@ window.Page_movies = (() => {
if (_activeTab === 'filme') await _renderFilme(content);
if (_activeTab === 'promis') _renderPromis(content);
if (_activeTab === 'hdm') await _renderHundDesMonats(content);
}
// ----------------------------------------------------------
@ -85,11 +86,6 @@ window.Page_movies = (() => {
content.innerHTML = `
<div class="movies-controls">
<div class="movies-search-row">
<svg class="ph-icon movies-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input type="search" id="movies-search" class="form-control movies-search-input"
placeholder="Film, Serie oder Rasse suchen …" value="${_esc(_search)}" autocomplete="off">
</div>
<div class="movies-filter-row">
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</button>
@ -139,11 +135,6 @@ window.Page_movies = (() => {
});
});
content.querySelector('#movies-search')?.addEventListener('input', e => {
_search = e.target.value.trim().toLowerCase();
_renderMovieGrid(content.querySelector('#movie-grid'));
});
content.querySelector('#movies-sort')?.addEventListener('change', async e => {
_sort = e.target.value;
const grid = content.querySelector('#movie-grid');
@ -162,14 +153,6 @@ window.Page_movies = (() => {
if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund);
if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund);
if (_filter === 'top') list = list.filter(f => (f.imdb_rating || 0) >= 7.5 || f.bewertung_avg >= 4.0);
if (_search) {
list = list.filter(f =>
(f.titel || '').toLowerCase().includes(_search) ||
(f.hund_rasse || '').toLowerCase().includes(_search) ||
(f.genre || '').toLowerCase().includes(_search) ||
(f.beschreibung || '').toLowerCase().includes(_search)
);
}
const countEl = document.getElementById('movies-count');
if (countEl) countEl.textContent = `${list.length} Einträge`;

View file

@ -1,320 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Presse Ban Yaro</title>
<meta name="description" content="Pressematerial, Logos und Screenshots für Redaktionen Ban Yaro Hunde-App">
<meta name="robots" content="noindex">
<link rel="icon" href="/icons/favicon.ico">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #b97c2a;
--primary-light: #f5ede0;
--text: #1a1a1a;
--muted: #666;
--border: #e5e0d8;
--bg: #faf9f7;
--white: #fff;
--radius: 12px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
/* Header */
header {
background: var(--white);
border-bottom: 1px solid var(--border);
padding: 1.25rem 2rem;
display: flex;
align-items: center;
gap: 1rem;
}
header img { width: 40px; height: 40px; border-radius: 10px; }
header .brand { font-size: 1.25rem; font-weight: 700; color: var(--text); }
header .brand span { color: var(--primary); }
header a { margin-left: auto; color: var(--primary); font-size: .9rem; text-decoration: none; }
/* Layout */
.container { max-width: 860px; margin: 0 auto; padding: 3rem 1.5rem; }
h1 { font-size: 2rem; font-weight: 800; margin-bottom: .5rem; }
h2 { font-size: 1.25rem; font-weight: 700; margin-bottom: 1.25rem; color: var(--text); }
.lead { color: var(--muted); margin-bottom: 3rem; font-size: 1.05rem; }
/* Sections */
section { margin-bottom: 3.5rem; }
.section-label {
font-size: .75rem; font-weight: 700; letter-spacing: .08em;
text-transform: uppercase; color: var(--primary);
margin-bottom: .75rem;
}
/* Press release */
.pressemitteilung {
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem 2.5rem;
}
.pressemitteilung .pm-meta {
font-size: .85rem; color: var(--muted); margin-bottom: 1.5rem;
}
.pressemitteilung h3 {
font-size: 1.4rem; font-weight: 800; margin-bottom: .35rem; line-height: 1.3;
}
.pressemitteilung .pm-sub {
font-size: .95rem; color: var(--muted); font-style: italic; margin-bottom: 1.5rem;
}
.pressemitteilung p { margin-bottom: 1rem; color: #333; }
.pressemitteilung blockquote {
border-left: 3px solid var(--primary);
padding-left: 1rem;
margin: 1.5rem 0;
color: #444;
font-style: italic;
}
/* Downloads */
.download-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}
.download-card {
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
text-decoration: none;
color: var(--text);
transition: box-shadow .15s;
}
.download-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); }
.download-card .thumb {
width: 100%; aspect-ratio: 9/16; object-fit: cover;
background: var(--primary-light);
display: block;
}
.download-card.logo-card .thumb {
aspect-ratio: 1; object-fit: contain; padding: 1.5rem;
}
.download-card .card-label {
padding: .6rem .85rem;
font-size: .8rem; color: var(--muted);
display: flex; align-items: center; justify-content: space-between;
}
.download-card .card-label span { font-weight: 600; color: var(--text); font-size: .85rem; }
.dl-icon { color: var(--primary); font-size: 1rem; }
/* Founder */
.founder-card {
display: flex; gap: 2rem; align-items: flex-start;
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
}
.founder-card img {
width: 140px; height: 140px;
border-radius: 50%; object-fit: cover; flex-shrink: 0;
border: 3px solid var(--primary-light);
}
.founder-card h3 { font-size: 1.1rem; font-weight: 700; margin-bottom: .25rem; }
.founder-card .role { color: var(--primary); font-size: .9rem; margin-bottom: .75rem; }
.founder-card p { color: #444; font-size: .95rem; }
/* Facts */
.facts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.fact {
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
}
.fact .fact-label { font-size: .8rem; color: var(--muted); margin-bottom: .25rem; }
.fact .fact-value { font-size: 1rem; font-weight: 700; }
/* Contact */
.contact-box {
background: var(--primary-light);
border-radius: var(--radius);
padding: 1.75rem 2rem;
}
.contact-box p { margin-bottom: .35rem; }
.contact-box a { color: var(--primary); font-weight: 600; text-decoration: none; }
/* Boilerplate */
.boilerplate {
background: var(--white);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem 2rem;
font-size: .9rem;
color: #444;
position: relative;
}
.copy-btn {
position: absolute; top: 1rem; right: 1rem;
background: var(--primary); color: white;
border: none; border-radius: 6px;
padding: .35rem .75rem; font-size: .75rem;
cursor: pointer; font-weight: 600;
}
.copy-btn:hover { opacity: .85; }
@media (max-width: 600px) {
.founder-card { flex-direction: column; }
.founder-card img { width: 100px; height: 100px; }
.pressemitteilung { padding: 1.5rem; }
h1 { font-size: 1.5rem; }
}
</style>
</head>
<body>
<header>
<img src="/icons/icon-180.png" alt="Ban Yaro Icon">
<div class="brand">Ban <span>Yaro</span></div>
<a href="https://banyaro.app">← Zur App</a>
</header>
<div class="container">
<h1>Pressematerial</h1>
<p class="lead">Logos, Screenshots und Hintergrundinformationen für Redaktionen. Alle Materialien sind zur redaktionellen Verwendung freigegeben.</p>
<!-- Pressemitteilung -->
<section>
<div class="section-label">Pressemitteilung</div>
<div class="pressemitteilung">
<div class="pm-meta">Ebersberg, 1. Mai 2026 — zur sofortigen Veröffentlichung freigegeben</div>
<h3>Vom Gipfelfoto bis zum Giftköder-Alarm: App begleitet Hundehalter durch den ganzen Alltag</h3>
<p class="pm-sub">banyaro.app bündelt Tagebuch, Gesundheitsakte und Echtzeit-Warnungen in einer kostenlosen Hunde-App</p>
<p>Manche Gassi-Runden sind einfach unvergesslich — der erste Schnee, der perfekte Sonnenuntergang, die Stelle am Bach, an der der Hund immer ins Wasser springt. Andere hinterlassen Angst: Ein verdächtiges Häufchen am Wegesrand, ein Hund der plötzlich würgt.</p>
<p>Für beides gibt es jetzt eine App: <strong>banyaro.app</strong> ist eine kostenlose Hunde-App aus Bayern, die den ganzen Alltag mit Hund begleitet — von den schönsten Momenten bis zu den gefährlichen.</p>
<p>Im <strong>Hunde-Tagebuch</strong> lassen sich Fotos, Notizen und Erinnerungen sammeln, in der <strong>Gesundheitsakte</strong> Impftermine, Medikamente und Tierarztbesuche verwalten. Die interaktive <strong>Karte</strong> zeigt die besten Hundewiesen, Wasserstellen und Auslaufgebiete in der Umgebung — und die schönsten Routen für die nächste Gassi-Runde.</p>
<p>Der <strong>Giftköder-Alarm</strong> funktioniert nach dem Prinzip der Schwarmintelligenz: Wer einen verdächtigen Fund meldet, macht ihn sofort auf der Karte für alle anderen Hundehalter in der Region sichtbar. Keine Facebook-Gruppe, kein verschwundener Post — die Warnung bleibt dauerhaft abrufbar.</p>
<blockquote>„Ich wollte eine App bauen, die sich wie ein stiller Begleiter anfühlt — die im Hintergrund läuft, Erinnerungen sammelt und im Ernstfall sofort warnt. Kein App Store, keine Kosten, keine Werbung."<br><strong>— René Degelmann, Gründer</strong></blockquote>
<p>banyaro.app ist direkt unter <strong>banyaro.app</strong> erreichbar — ohne Installation, direkt im Smartphone-Browser.</p>
</div>
</section>
<!-- Kurzprofil / Boilerplate -->
<section>
<div class="section-label">Über Ban Yaro — Kurztext für Redaktionen</div>
<div class="boilerplate" id="boilerplate-text">
<button class="copy-btn" onclick="copyBoilerplate()">Kopieren</button>
<p>Ban Yaro ist eine kostenlose Hunde-App für den deutschsprachigen Raum. Die App läuft als Progressive Web App direkt im Smartphone-Browser — ohne Installation über den App Store. Funktionen: Hunde-Tagebuch mit Fotos und Wetter, digitale Gesundheitsakte, interaktive Karte mit Hundewiesen und Giftköder-Alarm, Community-Forum und Trainingspläne. Gegründet 2024 von René Degelmann, Ebersberg bei München. Erreichbar unter banyaro.app.</p>
</div>
</section>
<!-- Screenshots -->
<section>
<div class="section-label">Screenshots — zur redaktionellen Verwendung freigegeben</div>
<div class="download-grid">
<a class="download-card" href="/img/screenshots/screen-1.jpg" download="banyaro-tagebuch.jpg">
<img class="thumb" src="/img/screenshots/screen-1.jpg" alt="Tagebuch">
<div class="card-label"><span>Tagebuch</span> <span class="dl-icon"></span></div>
</a>
<a class="download-card" href="/img/screenshots/screen-2.jpg" download="banyaro-karte-giftkoederr.jpg">
<img class="thumb" src="/img/screenshots/screen-2.jpg" alt="Karte & Giftköder-Alarm">
<div class="card-label"><span>Karte & Alarm</span> <span class="dl-icon"></span></div>
</a>
<a class="download-card" href="/img/screenshots/screen-3.jpg" download="banyaro-gesundheitsakte.jpg">
<img class="thumb" src="/img/screenshots/screen-3.jpg" alt="Gesundheitsakte">
<div class="card-label"><span>Gesundheitsakte</span> <span class="dl-icon"></span></div>
</a>
<a class="download-card" href="/img/screenshots/screen-9.jpg" download="banyaro-forum.jpg">
<img class="thumb" src="/img/screenshots/screen-9.jpg" alt="Forum & Community">
<div class="card-label"><span>Forum</span> <span class="dl-icon"></span></div>
</a>
</div>
</section>
<!-- Logo -->
<section>
<div class="section-label">Logo</div>
<div class="download-grid">
<a class="download-card logo-card" href="/icons/icon-512.png" download="banyaro-logo-512.png">
<img class="thumb" src="/icons/icon-512.png" alt="Ban Yaro Logo">
<div class="card-label"><span>Logo PNG 512px</span> <span class="dl-icon"></span></div>
</a>
<a class="download-card logo-card" href="/icons/icon-192.png" download="banyaro-logo-192.png">
<img class="thumb" src="/icons/icon-192.png" alt="Ban Yaro Logo 192">
<div class="card-label"><span>Logo PNG 192px</span> <span class="dl-icon"></span></div>
</a>
</div>
</section>
<!-- Gründer -->
<section>
<div class="section-label">Gründer</div>
<div class="founder-card">
<img src="/icons/founder.jpg" alt="René Degelmann mit Ban Yaro">
<div>
<h3>René Degelmann</h3>
<div class="role">Gründer & Entwickler, Ban Yaro</div>
<p>René Degelmann ist Softwareentwickler aus Ebersberg bei München. Ban Yaro hat er für seinen eigenen Hund gebaut — und dann gemerkt, dass tausende andere Hundehalter das gleiche brauchen. Die App entstand ohne Investoren, ohne App-Store-Zwang und ohne Werbung.</p>
</div>
</div>
<p style="margin-top:.75rem; font-size:.8rem; color:var(--muted);">Foto zur redaktionellen Verwendung freigegeben — <a href="/icons/founder.jpg" download="rene-degelmann-ban-yaro.jpg" style="color:var(--primary)">herunterladen ↓</a></p>
</section>
<!-- Eckdaten -->
<section>
<div class="section-label">Eckdaten</div>
<div class="facts-grid">
<div class="fact"><div class="fact-label">Gegründet</div><div class="fact-value">2026</div></div>
<div class="fact"><div class="fact-label">Sitz</div><div class="fact-value">Ebersberg bei München</div></div>
<div class="fact"><div class="fact-label">Plattform</div><div class="fact-value">Progressive Web App</div></div>
<div class="fact"><div class="fact-label">Preis</div><div class="fact-value">Kostenlos</div></div>
<div class="fact"><div class="fact-label">Sprache</div><div class="fact-value">Deutsch</div></div>
<div class="fact"><div class="fact-label">Zielmarkt</div><div class="fact-value">D-A-CH</div></div>
</div>
</section>
<!-- Pressekontakt -->
<section>
<div class="section-label">Pressekontakt</div>
<div class="contact-box">
<p><strong>René Degelmann</strong></p>
<p>Ringstr. 26 · 85560 Ebersberg</p>
<p>Telefon: <a href="tel:+4917112096622">0171 1209622</a></p>
<p>E-Mail: <a href="mailto:partner@banyaro.app">partner@banyaro.app</a></p>
<p>Web: <a href="https://banyaro.app">banyaro.app</a></p>
</div>
</section>
</div>
<script>
function copyBoilerplate() {
const text = document.getElementById('boilerplate-text').innerText.replace('Kopieren', '').trim();
navigator.clipboard.writeText(text).then(() => {
const btn = document.querySelector('.copy-btn');
btn.textContent = 'Kopiert ✓';
setTimeout(() => btn.textContent = 'Kopieren', 2000);
});
}
</script>
</body>
</html>

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v597';
const CACHE_VERSION = 'by-v583';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache