Compare commits

..

22 commits

Author SHA1 Message Date
031c6028ac Feature: HdM Community-Vote — alle öffentlichen Hunde wählbar, eigene ausgenommen, SW by-v597 2026-05-02 08:44:59 +02:00
83958cbb0b Fix: Forum Tab-Marquee scrollt jetzt bis zum Textende (Padding eingerechnet), SW by-v596 2026-05-02 08:39:22 +02:00
d1572c52bc Feature: Forum Tab-Pills — Marquee-Scroll bei Hover für abgeschnittenen Text, SW by-v595 2026-05-02 08:35:49 +02:00
7474e10031 Fix: Filme-Standardsortierung → IMDb DESC, Erscheinungsjahr DESC 2026-05-02 08:28:47 +02:00
07a888bbd8 Feature: Filme-DB auf 126 Einträge aufgeblasen (+58 neue), Seed immer aktiv
- 58 neue Filme/Serien/Dokus: Bluey, Best in Show, Ginga Gin,
  All Dogs Go to Heaven, Oliver & Co., Cats & Dogs, White Dog,
  The Dog House, Supervet, The Champions, Alpha u.v.m.
- seed_movies() läuft jetzt immer (INSERT OR IGNORE), nicht nur wenn DB leer
2026-05-02 08:25:51 +02:00
ea2a83b29e Feature: Filme-Suche, HdM ins Forum + Gewinner-Badge im Profil, SW by-v594
- Filme-Seite: Suchfeld (filtert live nach Titel, Rasse, Genre, Beschreibung)
- Filme-Seite: Tab "Hund des Monats" entfernt
- Forum: kompakte HdM-Kachel über der Suche (Sieger + Stimmen), Klick öffnet Abstimmungs-Modal
- Hundeprofil: goldene Badges für jeden gewonnenen Monat (🏆 Mai 2026 …)
- DB: Tabelle hund_des_monats_wins (dauerhaft, dog_id + monat + stimmen)
- Scheduler: Job am 1. des Monats 00:05 — schreibt Vormonats-Sieger, Push an Besitzer
- Dogs-API: liefert hdm_wins[] pro Hund mit
2026-05-02 08:12:29 +02:00
d00284184b Feature: Moderation SLA — Altersanzeige + Overdue-Alarm täglich 12:00, SW by-v591 2026-05-01 19:49:02 +02:00
87039994ce Feature: Moderation History — Log für alle 4 Bereiche, resolved_by/at Migration, SW by-v590 2026-05-01 19:44:59 +02:00
e2cd32a550 Feature: Moderation — Forum-Meldungen + POI-Korrekturen, SW by-v589 2026-05-01 19:37:46 +02:00
020153484a Feature: Moderation-Tab — Action-Items-Karte oben, SW by-v588 2026-05-01 19:34:26 +02:00
df8c4cc279 Feature: Tagesmail — Action Items + neue Nutzer heute 2026-05-01 19:31:42 +02:00
d04110c2ae Feature: Admin Action-Items-Karte über Tabs, SW by-v587 2026-05-01 19:29:23 +02:00
fb7bbe5ccc Fix: UI.modal.open() statt UI.modal() im Outreach-Log, SW by-v586 2026-05-01 19:21:38 +02:00
775cda9a67 Feature: Outreach-Log — Mail-Inhalt per Klick anzeigen, SW by-v585 2026-05-01 19:19:03 +02:00
8bdd67573e Fix: form-label Kontrast — c-text statt c-text-secondary, SW by-v584 2026-05-01 18:58:57 +02:00
9cc7b7258a Fix: Telefonnummer im Pressekontakt ergänzt 2026-05-01 18:52:02 +02:00
a6e123a1fd Fix: Gründungsjahr 2026 auf Presseseite 2026-05-01 18:45:53 +02:00
2cc4252120 Feature: Presseseite /presse mit Pressemitteilung, Screenshots, Logos, Gründerfoto 2026-05-01 18:42:59 +02:00
b6fdb23292 Cleanup: Influencer-Runde-2-Vorlagen aus Migration entfernt (bereits versendet) 2026-05-01 18:15:30 +02:00
5e0dcde523 Fix: Pflicht-Footer in Outreach-Mails (UWG §7, DSGVO Art. 14, Anschrift) 2026-05-01 17:50:48 +02:00
ab197d3ca2 Fix: Date-Header in Outreach-Mails (Spam-Prävention) 2026-05-01 17:46:01 +02:00
37ad20a096 Outreach: 7 personalisierte Influencer-Mail-Vorlagen (Runde 2)
@nami.and.tommy, @brina.explores, @heimatherzen, @pfotentick,
@flummis_diary, @verwolft, @babybearyuki — INSERT OR IGNORE Migration
2026-05-01 17:34:20 +02:00
19 changed files with 1445 additions and 74 deletions

View file

@ -1072,6 +1072,19 @@ def _migrate(conn_factory):
pass pass
logger.info("Migration: wiki_rassen Anreicherungs-Felder bereit.") 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 # Wiki: Züchter-Verzeichnis
conn.executescript(""" conn.executescript("""
CREATE TABLE IF NOT EXISTS wiki_zuchter ( CREATE TABLE IF NOT EXISTS wiki_zuchter (
@ -1631,3 +1644,16 @@ def _migrate(conn_factory):
conn.execute("UPDATE training_exercises SET js_exercise_id=? WHERE id=?", (js_id, row['id'])) 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)") 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.") 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,6 +1429,13 @@ 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 # /partner — Influencer-Landingpage
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@app.get("/partner") @app.get("/partner")

View file

@ -97,6 +97,40 @@ class ThreadAdminPatch(BaseModel):
is_deleted: Optional[int] = None 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 # GET /api/admin/stats
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -75,6 +75,21 @@ async def list_dogs(user=Depends(get_current_user)):
d = dict(r) d = dict(r)
d["is_guest"] = True d["is_guest"] = True
result.append(d) 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 return result

View file

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

View file

@ -94,6 +94,71 @@ _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": "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": "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}, {"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 = [ _SEED_PROMIS = [
@ -111,27 +176,25 @@ _SEED_PROMIS = [
def seed_movies(): def seed_movies():
"""Füllt die movies-Tabelle beim ersten Start (idempotent per INSERT OR IGNORE).""" """Füllt die movies-Tabelle mit allen Seed-Einträgen (idempotent per INSERT OR IGNORE)."""
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
with db() as conn: with db() as conn:
count = conn.execute("SELECT COUNT(*) FROM movies").fetchone()[0] for i, f in enumerate(_SEED_FILME):
if count == 0: conn.execute("""
for i, f in enumerate(_SEED_FILME): INSERT OR IGNORE INTO movies
conn.execute(""" (id, titel, originaltitel, jahr, genre, typ, hund_rasse,
INSERT OR IGNORE INTO movies stirbt_der_hund, beschreibung, bild_emoji, imdb_rating,
(id, titel, originaltitel, jahr, genre, typ, hund_rasse, streaming, sort_order)
stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
streaming, sort_order) """, (
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) f["id"], f["titel"], f.get("originaltitel"),
""", ( f.get("jahr"), f.get("genre"), f.get("typ", "film"),
f["id"], f["titel"], f.get("originaltitel"), f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0,
f.get("jahr"), f.get("genre"), f.get("typ", "film"), f.get("beschreibung"), f.get("bild_emoji", "🐾"),
f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0, f.get("imdb_rating"), f.get("streaming"), i,
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.")
))
logger.info(f"movies: {len(_SEED_FILME)} Filme geseedet.")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -180,7 +243,7 @@ _SORT_COLS = {
"jahr_asc": "m.jahr ASC", "jahr_asc": "m.jahr ASC",
"imdb": "m.imdb_rating DESC", "imdb": "m.imdb_rating DESC",
"bewertung": "community_avg DESC", "bewertung": "community_avg DESC",
"default": "m.sort_order ASC, m.jahr DESC", "default": "CASE WHEN m.imdb_rating IS NULL THEN 1 ELSE 0 END, m.imdb_rating DESC, m.jahr DESC",
} }
@router.get("/filme") @router.get("/filme")
@ -323,6 +386,34 @@ 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} 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") @router.post("/hund-des-monats/vote")
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)): async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
monat = datetime.now().strftime("%Y-%m") monat = datetime.now().strftime("%Y-%m")
@ -330,7 +421,9 @@ 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() dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone()
if not dog: if not dog:
raise HTTPException(404, "Hund nicht gefunden.") raise HTTPException(404, "Hund nicht gefunden.")
if dog["user_id"] != user["id"] and not dog["is_public"]: if dog["user_id"] == user["id"]:
raise HTTPException(403, "Du kannst nicht für deinen eigenen Hund abstimmen.")
if not dog["is_public"]:
raise HTTPException(403, "Dieser Hund ist nicht öffentlich.") raise HTTPException(403, "Dieser Hund ist nicht öffentlich.")
conn.execute(""" conn.execute("""
INSERT INTO hund_des_monats_votes (user_id, dog_id, monat) INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)

View file

@ -6,7 +6,7 @@ import smtplib
import ssl import ssl
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.utils import formataddr from email.utils import formataddr, formatdate
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
@ -87,6 +87,7 @@ def _imap_save_sent(msg_bytes: bytes, account: str):
def _build_message(to: str, subject: str, body: str, account: str, html: str = None) -> MIMEMultipart: def _build_message(to: str, subject: str, body: str, account: str, html: str = None) -> MIMEMultipart:
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["Date"] = formatdate(localtime=False) # UTC explizit, Container hat keine lokale TZ
msg["Subject"] = subject msg["Subject"] = subject
msg["From"] = formataddr((acc["name"], acc["from"])) msg["From"] = formataddr((acc["name"], acc["from"]))
msg["To"] = to msg["To"] = to
@ -97,11 +98,22 @@ def _build_message(to: str, subject: str, body: str, account: str, html: str = N
return msg 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): def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html: str = None):
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"] acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
if not acc["user"] or not acc["pass"]: if not acc["user"] or not acc["pass"]:
raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.") raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.")
msg = _build_message(to, subject, body, account, html=html) msg = _build_message(to, subject, body + _LEGAL_FOOTER, account, html=html)
msg_bytes = msg.as_bytes() msg_bytes = msg.as_bytes()
ctx = ssl.create_default_context() ctx = ssl.create_default_context()
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s: with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s:
@ -255,7 +267,7 @@ def send_support_mail(to: str, subject: str, body: str):
def outreach_log_endpoint(user=Depends(require_admin)): def outreach_log_endpoint(user=Depends(require_admin)):
with db() as conn: with db() as conn:
rows = conn.execute( rows = conn.execute(
"""SELECT ol.id, ol.recipient, ol.subject, ol.sent_at, """SELECT ol.id, ol.recipient, ol.subject, ol.body, ol.sent_at,
ol.from_account, u.name AS sent_by_name ol.from_account, u.name AS sent_by_name
FROM outreach_log ol FROM outreach_log ol
JOIN users u ON u.id = ol.sent_by JOIN users u ON u.id = ol.sent_by

View file

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

View file

@ -100,6 +100,14 @@ def start():
replace_existing=True, replace_existing=True,
misfire_grace_time=1800, 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 # 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht
_scheduler.add_job( _scheduler.add_job(
_job_quarterly_report, _job_quarterly_report,
@ -116,8 +124,16 @@ def start():
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, 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() _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, 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, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).")
def stop(): def stop():
@ -650,6 +666,115 @@ 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) # JOB: Status-Report per Mail (täglich 06:00 Uhr)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _job_status_report(): async def _job_status_report():
@ -677,6 +802,7 @@ async def _job_status_report():
# Community # Community
metrics["users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] 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["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").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] metrics["poison_active"] = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0").fetchone()[0]
@ -685,6 +811,28 @@ async def _job_status_report():
except Exception: except Exception:
metrics["lost_active"] = 0 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 # Wiki-Interesse
try: try:
metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0] metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0]
@ -736,6 +884,9 @@ async def _job_status_report():
<div style="opacity:.88;font-size:13px">{now_str} Uhr</div> <div style="opacity:.88;font-size:13px">{now_str} Uhr</div>
</div> </div>
<!-- Action Items -->
{_action_items_html(metrics)}
<!-- Scheduler-Status --> <!-- Scheduler-Status -->
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc"> <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> <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Scheduler-Jobs</div>
@ -749,14 +900,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="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"> <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 [ {"".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",metrics["users"]), ("Nutzer gesamt",metrics["users"]),
("Neue Nutzer heute",metrics["users_today"]),
("Hunde",metrics["dogs"]), ("Hunde",metrics["dogs"]),
("Tagebuch-Einträge",metrics["diary_entries"]), ("Tagebuch-Einträge",metrics["diary_entries"]),
("Aktive Giftköder",metrics["poison_active"]), ("Aktive Giftköder",metrics["poison_active"]),
("Vermisste Hunde",metrics["lost_active"]), ("Vermisste Hunde",metrics["lost_active"]),
("'So einen hab ich'",metrics["interesse_hat"]), ("'So einen hab ich'",metrics["interesse_hat"]),
("'Interessiert mich'",metrics["interesse_will"]), ("'Interessiert mich'",metrics["interesse_will"]),
("Züchter (pending)",metrics["zuchter_pending"]),
])} ])}
</div> </div>
</div> </div>
@ -770,19 +921,28 @@ async def _job_status_report():
</body> </body>
</html>""" </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} 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 === === Scheduler-Jobs ===
{job_rows_txt} {job_rows_txt}
=== Community === === Community ===
Nutzer: {metrics['users']} Nutzer gesamt: {metrics['users']} (+{metrics['users_today']} heute)
Hunde: {metrics['dogs']} Hunde: {metrics['dogs']}
Tagebuch-Einträge: {metrics['diary_entries']} Tagebuch-Einträge: {metrics['diary_entries']}
Aktive Giftköder: {metrics['poison_active']} Aktive Giftköder: {metrics['poison_active']}
Vermisste Hunde: {metrics['lost_active']} Vermisste Hunde: {metrics['lost_active']}
'So einen hab ich': {metrics['interesse_hat']} 'So einen hab ich': {metrics['interesse_hat']}
'Interessiert mich': {metrics['interesse_will']} 'Interessiert mich': {metrics['interesse_will']}
Züchter (pending): {metrics['zuchter_pending']}
""" """
try: try:
@ -958,3 +1118,57 @@ def _compute_milestone(today: date, bday: date, dog_name: str):
return titel, text return titel, text
return None 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 { .form-label {
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
color: var(--c-text-secondary); color: var(--c-text);
} }
/* iOS Safari: font-size < 16px triggert Auto-Zoom beim Fokus — muss alle Klassen überschreiben */ /* iOS Safari: font-size < 16px triggert Auto-Zoom beim Fokus — muss alle Klassen überschreiben */
@ -4179,6 +4179,19 @@ html.modal-open {
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 10rem; /* prevents single pill from being wider than ~160px on mobile */ 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) */ /* Category badge (colored pill) */
.forum-category-badge { .forum-category-badge {
@ -4200,6 +4213,59 @@ html.modal-open {
.forum-category-badge--tauschboerse { background: #fce7f3; color: #9d174d; } .forum-category-badge--tauschboerse { background: #fce7f3; color: #9d174d; }
/* Search */ /* 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 { .forum-search-wrap {
position: relative; position: relative;
} }
@ -4918,6 +4984,25 @@ html.modal-open {
} }
/* Filter-Row */ /* 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 { .movies-filter-row {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
@ -5156,11 +5241,19 @@ html.modal-open {
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
} }
/* Kandidaten-Suche */
.hdm-kandidaten-search {
margin-bottom: var(--space-3);
}
/* Vote-Grid */ /* Vote-Grid */
.hdm-vote-grid { .hdm-vote-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: var(--space-3); gap: var(--space-3);
max-height: 340px;
overflow-y: auto;
padding-right: var(--space-1);
} }
.hdm-vote-card { .hdm-vote-card {
@ -5972,6 +6065,21 @@ html.modal-open {
cursor: pointer; 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 --- */ /* --- Foto-Editor Modal --- */
.photo-editor { display: flex; flex-direction: column; gap: var(--space-3); align-items: center; } .photo-editor { display: flex; flex-direction: column; gap: var(--space-3); align-items: center; }
.photo-editor-preview { .photo-editor-preview {

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

View file

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

View file

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

View file

@ -48,6 +48,9 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
function _render() { function _render() {
_container.innerHTML = ` _container.innerHTML = `
<!-- Action Items -->
<div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div>
<!-- Tabs --> <!-- Tabs -->
<div class="by-tabs adm-tabs" id="adm-tabs"> <div class="by-tabs adm-tabs" id="adm-tabs">
${TABS.map(t => ` ${TABS.map(t => `
@ -73,9 +76,68 @@ window.Page_admin = (() => {
}); });
}); });
_renderActionItems();
_renderTab(); _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() { async function _renderTab() {
const el = _container.querySelector('#adm-content'); const el = _container.querySelector('#adm-content');
if (!el) return; if (!el) return;
@ -1398,6 +1460,43 @@ window.Page_admin = (() => {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// TAB: MODERATION // 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) { async function _renderModeration(el) {
el.innerHTML = ` el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)"> <div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
@ -1412,12 +1511,52 @@ window.Page_admin = (() => {
async function _loadModeration(el) { async function _loadModeration(el) {
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`; el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
const [zuchter, fotos] = await Promise.all([ const [zuchter, fotos, reports, poiEdits] = await Promise.all([
API.get('/wiki/zuchter/pending').catch(() => []), API.get('/wiki/zuchter/pending').catch(() => []),
API.get('/wiki/foto-submissions').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');
let html = ''; 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>`;
// --- Züchter-Einreichungen --- // --- Züchter-Einreichungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold); html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
@ -1425,23 +1564,24 @@ window.Page_admin = (() => {
margin-bottom:var(--space-3)"> margin-bottom:var(--space-3)">
Züchter-Einreichungen Züchter-Einreichungen
<span style="background:var(--c-primary);color:#fff;border-radius:999px; <span style="background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchter.length}</span> padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchterPending.length}</span>
</h3>`; </h3>`;
if (!zuchter.length) { if (!zuchterPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-6)">Keine ausstehenden Einreichungen.</p>`; html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Einreichungen.</p>`;
} else { } else {
html += `<div class="card adm-table-card" style="margin-bottom:var(--space-6)"><div class="adm-table-scroll"><table class="adm-table"> html += `<div class="card adm-table-card" style="margin-bottom:var(--space-3)"><div class="adm-table-scroll"><table class="adm-table">
<thead><tr style="background:var(--c-surface-2);text-align:left"> <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">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">Website</th><th class="adm-th"></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>
</tr></thead><tbody> </tr></thead><tbody>
${zuchter.map((z, i) => ` ${zuchterPending.map((z, i) => `
<tr style="${i%2===1?'background:var(--c-surface-2)':''}"> <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" 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.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">${_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">${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">${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"> <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> <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>
@ -1450,6 +1590,10 @@ window.Page_admin = (() => {
</tr>`).join('')} </tr>`).join('')}
</tbody></table></div></div>`; </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 --- // --- Wiki-Foto-Einreichungen ---
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold); html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
@ -1457,19 +1601,20 @@ window.Page_admin = (() => {
margin-bottom:var(--space-3)"> margin-bottom:var(--space-3)">
Wiki-Foto-Einreichungen Wiki-Foto-Einreichungen
<span style="background:var(--c-primary);color:#fff;border-radius:999px; <span style="background:var(--c-primary);color:#fff;border-radius:999px;
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotos.length}</span> padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotosPending.length}</span>
</h3>`; </h3>`;
if (!fotos.length) { if (!fotosPending.length) {
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine ausstehenden Foto-Einreichungen.</p>`; html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Foto-Einreichungen.</p>`;
} else { } else {
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4)"> html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4);margin-bottom:var(--space-3)">
${fotos.map(f => ` ${fotosPending.map(f => `
<div class="card" style="padding:var(--space-4)"> <div class="card" style="padding:var(--space-4)">
<img src="${_esc(f.foto_url)}" alt="" <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)"> 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-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-3)">von ${_esc(f.user_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>
${f.aktuell_foto ? `<img src="${_esc(f.aktuell_foto)}" alt="Aktuell" ${f.aktuell_foto ? `<img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm); style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm);
opacity:.5;margin-bottom:var(--space-2)"> opacity:.5;margin-bottom:var(--space-2)">
@ -1482,6 +1627,111 @@ window.Page_admin = (() => {
</div>`; </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; el.innerHTML = html;
// Züchter freigeben // Züchter freigeben
@ -1520,6 +1770,41 @@ window.Page_admin = (() => {
await _loadModeration(el); 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; }
});
});
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -2134,8 +2419,10 @@ window.Page_admin = (() => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${log.map(l => ` ${log.map((l, i) => `
<tr style="border-bottom:1px solid var(--c-border)"> <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=''">
<td style="padding:var(--space-2)">${accountBadge(l.from_account)}</td> <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)">${_esc(l.recipient)}</td>
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td> <td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td>
@ -2149,6 +2436,28 @@ window.Page_admin = (() => {
</div> </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 // Vorlage in Compose laden
function _loadTplIntoCompose(id) { function _loadTplIntoCompose(id) {
const tpl = templates.find(t => t.id === id); const tpl = templates.find(t => t.id === id);

View file

@ -97,8 +97,19 @@ window.Page_dog_profile = (() => {
<h2 style="font-size:var(--text-2xl);font-weight:700; <h2 style="font-size:var(--text-2xl);font-weight:700;
color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2> color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2>
${dog.rasse ${dog.rasse
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-5)">${_esc(dog.rasse)}</p>` ? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${_esc(dog.rasse)}</p>`
: `<p style="margin:0 0 var(--space-5)"></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>`}
<!-- Info-Grid --> <!-- Info-Grid -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3); <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);

View file

@ -66,6 +66,7 @@ window.Page_forum = (() => {
_container = container; _container = container;
_appState = appState; _appState = appState;
_render(); _render();
_loadHdmCard();
_loadThreads(true); _loadThreads(true);
} }
@ -98,15 +99,17 @@ window.Page_forum = (() => {
<div class="forum-category-tabs by-tabs" id="forum-tabs"> <div class="forum-category-tabs by-tabs" id="forum-tabs">
${KATEGORIEN.map(k => ` ${KATEGORIEN.map(k => `
<button class="by-tab ${k.key === _aktivKat ? 'active' : ''}" <button class="by-tab ${k.key === _aktivKat ? 'active' : ''}"
data-kat="${k.key}">${_esc(k.label)}</button> data-kat="${k.key}"><span class="by-tab-text">${_esc(k.label)}</span></button>
`).join('')} `).join('')}
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}" <button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
data-section="map">${UI.icon('users')} Mitgliederkarte</button> data-section="map"><span class="by-tab-text">${UI.icon('users')} Mitgliederkarte</span></button>
</div> </div>
<!-- Rechte Spalte: Suche + Threads --> <!-- Rechte Spalte: HdM-Kachel + Suche + Threads -->
<div class="forum-main-col"> <div class="forum-main-col">
<div id="forum-hdm-card"></div>
<div class="forum-search-wrap"> <div class="forum-search-wrap">
<input type="search" class="forum-search" id="forum-search" <input type="search" class="forum-search" id="forum-search"
placeholder="Forum durchsuchen…" autocomplete="off"> placeholder="Forum durchsuchen…" autocomplete="off">
@ -127,6 +130,23 @@ window.Page_forum = (() => {
const _tabCount = _tabsEl.querySelectorAll('.by-tab').length; const _tabCount = _tabsEl.querySelectorAll('.by-tab').length;
_tabsEl.style.setProperty('--forum-tab-cols', Math.ceil(_tabCount / 2)); _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 // Tab-Klicks
_tabsEl.addEventListener('click', e => { _tabsEl.addEventListener('click', e => {
const btn = e.target.closest('[data-kat], [data-section]'); const btn = e.target.closest('[data-kat], [data-section]');
@ -175,6 +195,177 @@ window.Page_forum = (() => {
document.getElementById('forum-rules-btn').addEventListener('click', _showRules); 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 // Threads laden
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -15,6 +15,7 @@ window.Page_movies = (() => {
let _filter = 'alle'; let _filter = 'alle';
let _typ = 'alle'; // alle | film | serie | doku let _typ = 'alle'; // alle | film | serie | doku
let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung
let _search = '';
// ---------------------------------------------------------- // ----------------------------------------------------------
// INIT // INIT
@ -41,7 +42,6 @@ window.Page_movies = (() => {
<div class="movies-tabs"> <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 === '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 === '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>
<div id="movies-tab-content"></div> <div id="movies-tab-content"></div>
`; `;
@ -66,7 +66,6 @@ window.Page_movies = (() => {
if (_activeTab === 'filme') await _renderFilme(content); if (_activeTab === 'filme') await _renderFilme(content);
if (_activeTab === 'promis') _renderPromis(content); if (_activeTab === 'promis') _renderPromis(content);
if (_activeTab === 'hdm') await _renderHundDesMonats(content);
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -86,6 +85,11 @@ window.Page_movies = (() => {
content.innerHTML = ` content.innerHTML = `
<div class="movies-controls"> <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"> <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 === '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> <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>
@ -135,6 +139,11 @@ 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 => { content.querySelector('#movies-sort')?.addEventListener('change', async e => {
_sort = e.target.value; _sort = e.target.value;
const grid = content.querySelector('#movie-grid'); const grid = content.querySelector('#movie-grid');
@ -153,6 +162,14 @@ window.Page_movies = (() => {
if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund); if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund);
if (_filter === 'ueberlebt') 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 (_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'); const countEl = document.getElementById('movies-count');
if (countEl) countEl.textContent = `${list.length} Einträge`; if (countEl) countEl.textContent = `${list.length} Einträge`;

320
backend/static/presse.html Normal file
View file

@ -0,0 +1,320 @@
<!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 Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v583'; const CACHE_VERSION = 'by-v597';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache