Compare commits
No commits in common. "031c6028ac0040912a563ac2cbd4ea765b2bbf3c" and "062794c61a6e99597eaec0bc8824af567eaaadd9" have entirely different histories.
031c6028ac
...
062794c61a
19 changed files with 74 additions and 1445 deletions
|
|
@ -1072,19 +1072,6 @@ def _migrate(conn_factory):
|
|||
pass
|
||||
logger.info("Migration: wiki_rassen Anreicherungs-Felder bereit.")
|
||||
|
||||
# Moderation-Logging: resolved_by/at für forum_reports, verified_by/at/reject für wiki_zuchter
|
||||
for table, col, typedef in [
|
||||
("forum_reports", "resolved_by", "INTEGER"),
|
||||
("forum_reports", "resolved_at", "TEXT"),
|
||||
("wiki_zuchter", "verified_by", "INTEGER"),
|
||||
("wiki_zuchter", "verified_at", "TEXT"),
|
||||
("wiki_zuchter", "reject_reason", "TEXT"),
|
||||
]:
|
||||
try:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Wiki: Züchter-Verzeichnis
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS wiki_zuchter (
|
||||
|
|
@ -1644,16 +1631,3 @@ def _migrate(conn_factory):
|
|||
conn.execute("UPDATE training_exercises SET js_exercise_id=? WHERE id=?", (js_id, row['id']))
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_te_js_id ON training_exercises(js_exercise_id)")
|
||||
logger.info("Migration: training_exercises.js_exercise_id hinzugefügt, 'Fuß' bereinigt.")
|
||||
|
||||
# Hund des Monats — dauerhafte Gewinner-Tabelle
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS hund_des_monats_wins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
|
||||
monat TEXT NOT NULL,
|
||||
stimmen INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(dog_id, monat)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_hdm_wins_dog ON hund_des_monats_wins(dog_id);
|
||||
""")
|
||||
|
|
|
|||
|
|
@ -1429,13 +1429,6 @@ async def knigge_page():
|
|||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /presse — Presseseite
|
||||
# ------------------------------------------------------------------
|
||||
@app.get("/presse")
|
||||
async def presse():
|
||||
return FileResponse(f"{STATIC_DIR}/presse.html", headers={"Cache-Control": "max-age=3600"})
|
||||
|
||||
|
||||
# /partner — Influencer-Landingpage
|
||||
# ------------------------------------------------------------------
|
||||
@app.get("/partner")
|
||||
|
|
|
|||
|
|
@ -97,40 +97,6 @@ class ThreadAdminPatch(BaseModel):
|
|||
is_deleted: Optional[int] = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/admin/action-items
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/action-items")
|
||||
async def action_items(user=Depends(require_mod)):
|
||||
with db() as conn:
|
||||
jobs = conn.execute(
|
||||
"SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')"
|
||||
).fetchone()[0]
|
||||
breeders = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE breeder_status='pending'"
|
||||
).fetchone()[0]
|
||||
reports = conn.execute(
|
||||
"SELECT COUNT(*) FROM forum_reports WHERE resolved=0"
|
||||
).fetchone()[0]
|
||||
fotos = conn.execute(
|
||||
"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'"
|
||||
).fetchone()[0]
|
||||
poi_edits = conn.execute(
|
||||
"SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'"
|
||||
).fetchone()[0]
|
||||
users_today = conn.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')"
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"jobs_pending": jobs,
|
||||
"breeder_pending": breeders,
|
||||
"reports_open": reports,
|
||||
"fotos_pending": fotos,
|
||||
"poi_edits_pending": poi_edits,
|
||||
"users_today": users_today,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/admin/stats
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -75,21 +75,6 @@ async def list_dogs(user=Depends(get_current_user)):
|
|||
d = dict(r)
|
||||
d["is_guest"] = True
|
||||
result.append(d)
|
||||
|
||||
# HdM-Siege pro Hund anhängen
|
||||
if result:
|
||||
dog_ids = [d["id"] for d in result]
|
||||
with db() as conn:
|
||||
wins_rows = conn.execute(
|
||||
f"SELECT dog_id, monat FROM hund_des_monats_wins WHERE dog_id IN ({','.join('?'*len(dog_ids))}) ORDER BY monat DESC",
|
||||
dog_ids,
|
||||
).fetchall()
|
||||
wins_map: dict[int, list[str]] = {}
|
||||
for w in wins_rows:
|
||||
wins_map.setdefault(w["dog_id"], []).append(w["monat"])
|
||||
for d in result:
|
||||
d["hdm_wins"] = wins_map.get(d["id"], [])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
"""BAN YARO — Moderations-Panel Backend"""
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
|
|
@ -70,19 +69,17 @@ async def mod_stats(user=Depends(require_moderator)):
|
|||
async def mod_reports(user=Depends(require_moderator)):
|
||||
with db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved,
|
||||
r.created_at, r.resolved_at,
|
||||
u.name AS melder_name,
|
||||
m.name AS resolved_by_name,
|
||||
SELECT r.id, r.target_type, r.target_id, r.grund, r.resolved, r.created_at,
|
||||
u.name AS melder_name,
|
||||
CASE r.target_type
|
||||
WHEN 'thread' THEN (SELECT t.titel FROM forum_threads t WHERE t.id=r.target_id)
|
||||
WHEN 'post' THEN (SELECT SUBSTR(p.text,1,80) FROM forum_posts p WHERE p.id=r.target_id)
|
||||
END AS content_preview
|
||||
FROM forum_reports r
|
||||
LEFT JOIN users u ON u.id=r.user_id
|
||||
LEFT JOIN users m ON m.id=r.resolved_by
|
||||
ORDER BY r.resolved ASC, r.created_at DESC
|
||||
LIMIT 200
|
||||
WHERE r.resolved=0
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 100
|
||||
""").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
|
@ -100,12 +97,8 @@ async def mod_resolve_report(rid: int, user=Depends(require_moderator)):
|
|||
raise HTTPException(404, "Meldung nicht gefunden.")
|
||||
new_state = 0 if r["resolved"] else 1
|
||||
conn.execute(
|
||||
"""UPDATE forum_reports SET resolved=?, resolved_by=?, resolved_at=?
|
||||
WHERE id=?""",
|
||||
(new_state,
|
||||
user["id"] if new_state else None,
|
||||
datetime.utcnow().isoformat() if new_state else None,
|
||||
rid)
|
||||
"UPDATE forum_reports SET resolved=? WHERE id=?",
|
||||
(new_state, rid)
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
|
@ -196,19 +189,17 @@ async def mod_patch_user(uid: int, data: dict, user=Depends(require_moderator)):
|
|||
async def mod_fotos(user=Depends(require_moderator)):
|
||||
with db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT s.id, s.foto_url, s.status, s.created_at,
|
||||
s.reviewed_at, s.reject_reason,
|
||||
SELECT s.id, s.foto_url, s.created_at,
|
||||
COALESCE(s.rights_confirmed, 0) AS rights_confirmed,
|
||||
u.name AS user_name,
|
||||
m.name AS reviewed_by_name,
|
||||
r.name AS rasse_name, r.slug AS rasse_slug,
|
||||
u.name AS user_name,
|
||||
r.name AS rasse_name, r.slug AS rasse_slug,
|
||||
r.foto_url AS aktuell_foto
|
||||
FROM wiki_foto_submissions s
|
||||
LEFT JOIN users u ON u.id = s.user_id
|
||||
LEFT JOIN users m ON m.id = s.reviewed_by
|
||||
LEFT JOIN wiki_rassen r ON r.id = s.rasse_id
|
||||
ORDER BY s.status ASC, s.created_at ASC
|
||||
LIMIT 200
|
||||
WHERE s.status = 'pending'
|
||||
ORDER BY s.created_at ASC
|
||||
LIMIT 50
|
||||
""").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
|
@ -237,13 +228,11 @@ async def mod_poi_edits(user=Depends(require_moderator)):
|
|||
SELECT e.id, e.osm_id, e.poi_name, e.field,
|
||||
e.old_value, e.new_value, e.status,
|
||||
e.created_at, e.resolved_at,
|
||||
u.name AS einreicher_name,
|
||||
m.name AS mod_name
|
||||
u.name AS einreicher_name
|
||||
FROM osm_poi_edits e
|
||||
JOIN users u ON u.id = e.user_id
|
||||
LEFT JOIN users m ON m.id = e.mod_id
|
||||
ORDER BY e.status ASC, e.created_at DESC
|
||||
LIMIT 200
|
||||
LIMIT 100
|
||||
""").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
|
|
|||
|
|
@ -94,71 +94,6 @@ _SEED_FILME = [
|
|||
{"id": "white-fang", "titel": "Wolfsblut", "originaltitel": "White Fang", "jahr": 1991, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Wolf-Hund-Hybrid", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Ethan Hawke: Ein Wolf-Hund-Hybrid findet in Alaska zwischen Wildnis und Menschenwelt seinen Platz.", "bild_emoji": "❄️", "imdb_rating": 6.7},
|
||||
{"id": "because-of-winn-dixie","titel": "Winn-Dixie", "originaltitel": "Because of Winn-Dixie", "jahr": 2005, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein einsames Mädchen findet einen streunenden Hund im Supermarkt — und mit ihm eine ganze Gemeinschaft.", "bild_emoji": "🛒", "imdb_rating": 6.4},
|
||||
{"id": "lassie-2005", "titel": "Lassie (2005)", "jahr": 2005, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Britische Neuverfilmung mit Peter O'Toole. Lassie flieht aus Schottland und findet den langen Weg nach Yorkshire. Atmosphärisch.", "bild_emoji": "🏴", "imdb_rating": 6.7},
|
||||
# ── Neue Einträge: Animation ──────────────────────────────────────
|
||||
{"id": "all-dogs-go-to-heaven", "titel": "Alle Hunde kommen in den Himmel", "originaltitel": "All Dogs Go to Heaven", "jahr": 1989, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": True, "beschreibung": "Don Bluth-Klassiker: Gauner-Hund Charlie entkommt dem Himmel und sucht Rache — bis er sich in ein Waisenmädchen verliebt.", "bild_emoji": "😇", "imdb_rating": 6.8},
|
||||
{"id": "all-dogs-go-heaven-2", "titel": "Alle Hunde kommen in den Himmel 2", "originaltitel": "All Dogs Go to Heaven 2", "jahr": 1996, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Schäferhund / Mischling", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Charlie kehrt aus dem Himmel zurück nach San Francisco — mit Charlie Sheen als Synchronstimme.", "bild_emoji": "😇", "imdb_rating": 5.4},
|
||||
{"id": "oliver-and-company", "titel": "Oliver & Co.", "originaltitel": "Oliver & Company", "jahr": 1988, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Verschiedene (Dodger = Mischling)", "stirbt_der_hund": False, "beschreibung": "Disney modernisiert Oliver Twist: Ein obdachloser Kater schließt sich einer Hunde-Gang unter Anführer Dodger im New York der 1980er an.", "bild_emoji": "🎸", "imdb_rating": 6.7, "streaming": "Disney+"},
|
||||
{"id": "peanuts-movie", "titel": "Die Peanuts — Der Film", "originaltitel": "The Peanuts Movie", "jahr": 2015, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Snoopy und Charlie Brown auf der großen Leinwand. Perfekt für alle, die mit dem Kultcomic aufgewachsen sind.", "bild_emoji": "✈️", "imdb_rating": 7.0, "streaming": "Disney+"},
|
||||
{"id": "clifford-big-red-dog", "titel": "Clifford — Der große rote Hund", "originaltitel": "Clifford the Big Red Dog", "jahr": 2021, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Mischling (riesig)", "stirbt_der_hund": False, "beschreibung": "Der riesige rote Hund aus dem Kinderbuchklassiker kommt ins Kino — Chaos und Herz für die ganze Familie.", "bild_emoji": "🔴", "imdb_rating": 5.4, "streaming": "Amazon Prime"},
|
||||
{"id": "101-dalmatians-1996", "titel": "101 Dalmatiner (1996)", "originaltitel": "101 Dalmatians", "jahr": 1996, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Glenn Close als unvergessliche Cruella de Vil im Live-Action-Remake des Disney-Klassikers — böse, schrill und absolut unterhaltsam.", "bild_emoji": "🐾", "imdb_rating": 5.7, "streaming": "Disney+"},
|
||||
{"id": "102-dalmatians", "titel": "102 Dalmatiner", "originaltitel": "102 Dalmatians", "jahr": 2000, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Cruella ist scheinbar geläutert — aber die Dalmatiner-Welpen sind wieder in Gefahr. Fortsetzung mit Glenn Close.", "bild_emoji": "🐾", "imdb_rating": 4.9, "streaming": "Disney+"},
|
||||
{"id": "lady-and-tramp-2019", "titel": "Susi und Strolch (2019)", "originaltitel": "Lady and the Tramp", "jahr": 2019, "genre": "Familie/Romanze", "typ": "film", "hund_rasse": "Cocker Spaniel / Mischling", "stirbt_der_hund": False, "beschreibung": "Disney+ Live-Action-Remake mit echten Hunden: Die ikonische Spaghetti-Szene neu inszeniert, herzlich und mit modernem Charme.", "bild_emoji": "🍝", "imdb_rating": 6.4, "streaming": "Disney+"},
|
||||
{"id": "my-dog-tulip", "titel": "Mein Hund Tulip", "originaltitel": "My Dog Tulip", "jahr": 2009, "genre": "Animation/Drama", "typ": "film", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": True, "beschreibung": "Animierter Arthouse-Film nach J.R. Ackerley: Ein eigenbrötlerischer Engländer und die vorbehaltlose Liebe zu seiner Schäferhündin Tulip.", "bild_emoji": "🌷", "imdb_rating": 7.0},
|
||||
{"id": "bluey", "titel": "Bluey", "jahr": 2018, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Blue Heeler", "stirbt_der_hund": False, "beschreibung": "Australische Kinder-Animationsserie über eine Blue-Heeler-Familie, die weltweit Eltern und Kinder gleichermaßen begeistert. Meistgeliebte Kinderserie des 21. Jahrhunderts.", "bild_emoji": "💙", "imdb_rating": 9.0, "streaming": "Disney+"},
|
||||
{"id": "strays-2023", "titel": "Strays — Lass uns Hunde sein", "originaltitel": "Strays", "jahr": 2023, "genre": "Animation/Komödie", "typ": "film", "hund_rasse": "Mischling / Australian Shepherd", "stirbt_der_hund": False, "beschreibung": "Komplett obszöne Erwachsenen-Trickfilm-Komödie: Verlassener Hund will mit neuen Hundefreunden Rache am Herrchen nehmen. Nicht für Kinder!","bild_emoji": "🤬", "imdb_rating": 5.8, "streaming": "Amazon Prime"},
|
||||
# ── Neue Einträge: Familie/Drama ──────────────────────────────────
|
||||
{"id": "hotel-for-dogs", "titel": "Hotel für Hunde", "originaltitel": "Hotel for Dogs", "jahr": 2009, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Zwei Geschwister verwandeln ein leerstehendes Hotel in ein geheimes Paradies für Straßenhunde. Herzerwärmende Familienunterhaltung.", "bild_emoji": "🏨", "imdb_rating": 5.8},
|
||||
{"id": "snow-dogs", "titel": "Snowdogs", "originaltitel": "Snow Dogs", "jahr": 2002, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Siberian Husky", "stirbt_der_hund": False, "beschreibung": "Cuba Gooding Jr. erbt eine Schlittenhunde-Meute in Alaska und muss erst lernen, mit ihnen umzugehen. Leichte Disney-Komödie.", "bild_emoji": "🛷", "imdb_rating": 4.2, "streaming": "Disney+"},
|
||||
{"id": "a-dogs-way-home", "titel": "Auf dem Heimweg — Lassie und Ich", "originaltitel": "A Dog's Way Home", "jahr": 2019, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Pit-Bull-Mischling", "stirbt_der_hund": False, "beschreibung": "Hündin Bella verliert sich 400 Meilen von zu Hause entfernt und kämpft sich durch alle Widrigkeiten zurück zu ihrem Herrchen.", "bild_emoji": "🏡", "imdb_rating": 6.7, "streaming": "Netflix"},
|
||||
{"id": "fluke", "titel": "Fluke — Das fremde Ich", "originaltitel": "Fluke", "jahr": 1995, "genre": "Drama/Fantasy", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Mann stirbt und wird als Hund wiedergeboren — und kehrt zu seiner Familie zurück. Ungewöhnliches Dramafantasy mit tiefem emotionalem Kern.", "bild_emoji": "🔄", "imdb_rating": 6.2},
|
||||
{"id": "zeus-and-roxanne", "titel": "Zeus und Roxanne", "originaltitel": "Zeus and Roxanne", "jahr": 1997, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Hund und ein Delfin werden beste Freunde — und bringen dabei auch ihre Besitzer zusammen. Charmante Familienunterhaltung der 90er.", "bild_emoji": "🐬", "imdb_rating": 5.2},
|
||||
{"id": "benji-2018", "titel": "Benji (2018)", "originaltitel": "Benji", "jahr": 2018, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Netflix-Remake des Klassikers: Der streunende Hund Benji rettet erneut Kinder aus gefährlichen Händen — jetzt für eine neue Generation.", "bild_emoji": "🐾", "imdb_rating": 6.3, "streaming": "Netflix"},
|
||||
{"id": "ugly-dachshund", "titel": "Der hässliche Dackel", "originaltitel": "The Ugly Dachshund", "jahr": 1966, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutscher Dogge / Dackel", "stirbt_der_hund": False, "beschreibung": "Disney-Klassiker: Eine Harlekindogge wird von Dackeln aufgezogen und denkt, sie sei selbst ein Dackel. Harmlose Familienkomödie.", "bild_emoji": "😅", "imdb_rating": 6.4},
|
||||
{"id": "shiloh", "titel": "Shiloh — Mein treuer Freund", "originaltitel": "Shiloh", "jahr": 1996, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Junge in West Virginia rettet einen misshandelten Beagle vor seinem brutalen Besitzer — eine Geschichte über Mut und Gewissen.", "bild_emoji": "🌿", "imdb_rating": 6.7},
|
||||
{"id": "iron-will", "titel": "Iron Will", "jahr": 1994, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": False, "beschreibung": "Junger Mann rettet die Farm seiner Familie mit einem gewagten Schlittenhunde-Rennen von Kanada nach Minnesota. Inspirierend und episch.", "bild_emoji": "🏆", "imdb_rating": 6.7, "streaming": "Disney+"},
|
||||
{"id": "belle-et-sebastien", "titel": "Belle und Sébastien", "originaltitel": "Belle et Sébastien", "jahr": 2013, "genre": "Familie/Abenteuer", "typ": "film", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Französischer Familienfilm: Ein Waisenjunge und die riesige Berghündin Belle sind beste Freunde in den Alpen des Zweiten Weltkriegs.", "bild_emoji": "🏔️", "imdb_rating": 7.0},
|
||||
{"id": "dog-of-flanders", "titel": "Ein Hund von Flandern", "originaltitel": "A Dog of Flanders", "jahr": 1999, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Bouvier des Flandres", "stirbt_der_hund": True, "beschreibung": "Bewegende Verfilmung des Klassikers: Ein armer Junge in Belgien und sein Hund träumen von Kunst und Würde — mit tragischem Ende.", "bild_emoji": "🎨", "imdb_rating": 7.0},
|
||||
{"id": "underdog-2007", "titel": "Underdog — Ein Held auf vier Pfoten", "originaltitel": "Underdog", "jahr": 2007, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": False, "beschreibung": "Ein Laborhund erhält Superkräfte und wird zum maskierten Superhelden der Stadt. Leichte Disney-Familienkomödie nach der Zeichentrickserie.", "bild_emoji": "🦸", "imdb_rating": 5.1},
|
||||
{"id": "bingo-1991", "titel": "Bingo", "jahr": 1991, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein Zirkushund reist quer durch Amerika, um seinen jungen Herrchen zu finden. Kindheitserinnerung der frühen 90er.", "bild_emoji": "🎪", "imdb_rating": 5.8},
|
||||
{"id": "goodbye-my-lady", "titel": "Leb wohl, Lady", "originaltitel": "Goodbye, My Lady", "jahr": 1956, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Basenji", "stirbt_der_hund": False, "beschreibung": "Ein Junge im Mississippi-Sumpfland findet einen seltsamen lachenden Hund — und muss ihn am Ende zurückgeben. Zeitloser Klassiker.", "bild_emoji": "🌊", "imdb_rating": 7.1},
|
||||
{"id": "mitt-liv-som-hund", "titel": "Mein Leben als Hund", "originaltitel": "Mitt liv som hund", "jahr": 1985, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Lasse Hallströms schwedisches Meisterwerk: Ein Junge wird aufs Land geschickt und vergleicht sein Leben mit dem Schicksal von Laika.", "bild_emoji": "🇸🇪", "imdb_rating": 7.8},
|
||||
{"id": "homeward-bound-2", "titel": "Auf dem Weg nach Hause 2 — Im Großstadtdschungel", "originaltitel": "Homeward Bound II: Lost in San Francisco", "jahr": 1996, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Die Tiere aus Teil 1 verirren sich in San Francisco und müssen erneut den Weg nach Hause finden — diesmal durch die Stadt.", "bild_emoji": "🌉", "imdb_rating": 6.0, "streaming": "Disney+"},
|
||||
{"id": "alpha-2018", "titel": "Alpha", "jahr": 2018, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Wolf", "stirbt_der_hund": False, "beschreibung": "Vor 20.000 Jahren: Ein junger Jäger freundet sich mit einem verletzten Wolf an und legt damit den Grundstein für die Mensch-Hund-Beziehung.", "bild_emoji": "🐺", "imdb_rating": 6.7, "streaming": "Amazon Prime"},
|
||||
{"id": "nankyoku-monogatari", "titel": "Antarktis", "originaltitel": "Nankyoku Monogatari", "jahr": 1983, "genre": "Drama/Abenteuer", "typ": "film", "hund_rasse": "Sakhalin Husky", "stirbt_der_hund": True, "beschreibung": "Japanisches Meisterwerk: 15 Schlittenhunde werden 1958 in der Antarktis zurückgelassen — die wahre Geschichte zweier Überlebender.", "bild_emoji": "🇯🇵", "imdb_rating": 7.7},
|
||||
# ── Neue Einträge: Komödie ────────────────────────────────────────
|
||||
{"id": "cats-and-dogs", "titel": "Cats & Dogs", "jahr": 2001, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Beagle / Verschiedene", "stirbt_der_hund": False, "beschreibung": "Geheimdienstagenten auf vier Pfoten: Hunde gegen Katzen im Kampf um die Weltherrschaft. Spionagefilm-Parodie für die ganze Familie.", "bild_emoji": "🐱", "imdb_rating": 5.2},
|
||||
{"id": "cats-and-dogs-2", "titel": "Cats & Dogs 2 — Die Rache der Kitty Kahlohr", "originaltitel": "Cats & Dogs: The Revenge of Kitty Galore", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Fortsetzung: Hunde und Katzen müssen kooperieren, um eine wahnsinnige Superschurkin-Katze zu stoppen.", "bild_emoji": "😾", "imdb_rating": 4.1},
|
||||
{"id": "shaggy-dog-1959", "titel": "Der zottige Hund", "originaltitel": "The Shaggy Dog", "jahr": 1959, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Alter Ungarischer Hirtenhund (Sheepdogähnlich)", "stirbt_der_hund": False, "beschreibung": "Disney-Familienklassiker: Ein Teenager verwandelt sich durch einen magischen Ring immer wieder in einen Hund. Mit Fred MacMurray.", "bild_emoji": "✨", "imdb_rating": 6.3},
|
||||
{"id": "shaggy-dog-2006", "titel": "The Shaggy Dog (2006)", "originaltitel": "The Shaggy Dog", "jahr": 2006, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bobtail (Alter Englischer Schäferhund)", "stirbt_der_hund": False, "beschreibung": "Tim Allen als Staatsanwalt, der sich in einen Hund verwandelt — modernes Remake des Disney-Klassikers mit Slapstick-Humor.", "bild_emoji": "✨", "imdb_rating": 5.2, "streaming": "Disney+"},
|
||||
{"id": "marmaduke-2010", "titel": "Marmaduke", "jahr": 2010, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Deutsche Dogge", "stirbt_der_hund": False, "beschreibung": "Die riesige Comic-Dogge Marmaduke zieht nach Kalifornien und mischt die dortige Hunde-Gesellschaft auf. Lockerleichte Familienkomödie.", "bild_emoji": "😬", "imdb_rating": 4.6},
|
||||
{"id": "show-dogs", "titel": "Show Dogs", "jahr": 2018, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Rottweiler", "stirbt_der_hund": False, "beschreibung": "Ein Polizei-Rottweiler muss undercover bei einer Hundeschau ermitteln. Kindliche Spionagekomödie mit Will Arnett.", "bild_emoji": "🏅", "imdb_rating": 4.3},
|
||||
{"id": "must-love-dogs", "titel": "Must Love Dogs", "jahr": 2005, "genre": "Romanze/Komödie", "typ": "film", "hund_rasse": "Neufundländer", "stirbt_der_hund": False, "beschreibung": "Romantische Komödie: Eine frisch Geschiedene sucht online die Liebe — und ein Hund spielt dabei die entscheidende Rolle. Mit Diane Lane.", "bild_emoji": "💕", "imdb_rating": 6.0},
|
||||
{"id": "best-in-show", "titel": "Best in Show", "jahr": 2000, "genre": "Komödie/Mockumentary", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Christopher Guest's genialer Mockumentary über völlig überdrehte Hundeshow-Besucher beim Mayflower Dog Show — vernichtende Satire auf Hundebesitzer.", "bild_emoji": "🎭", "imdb_rating": 7.8},
|
||||
{"id": "wiener-dog", "titel": "Wiener-Dog", "jahr": 2016, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Dackel", "stirbt_der_hund": True, "beschreibung": "Todd Solondz' episodischer Indie-Film: Ein kleiner Dackel wandert durch mehrere skurrile Menschenleben. Dunkel, philosophisch, preisgekrönt.", "bild_emoji": "🌭", "imdb_rating": 6.6},
|
||||
# ── Neue Einträge: Action/Thriller ───────────────────────────────
|
||||
{"id": "mans-best-friend-1993", "titel": "Man's Best Friend", "originaltitel": "Man's Best Friend", "jahr": 1993, "genre": "Horror/Thriller", "typ": "film", "hund_rasse": "Mastiff", "stirbt_der_hund": True, "beschreibung": "Ein genetisch manipulierter Kettenhund bricht aus einem Labor aus und entpuppt sich als tödliche Bedrohung. B-Movie-Horrorklassiker.", "bild_emoji": "🧬", "imdb_rating": 5.1},
|
||||
{"id": "white-dog-1982", "titel": "White Dog", "originaltitel": "White Dog", "jahr": 1982, "genre": "Drama/Thriller", "typ": "film", "hund_rasse": "Weißer Schäferhund", "stirbt_der_hund": False, "beschreibung": "Samuel Fullers politisch brisanter Film: Ein weißer Schäferhund wurde auf schwarze Menschen abgerichtet — und soll nun umtrainiert werden.", "bild_emoji": "⚪", "imdb_rating": 7.2},
|
||||
{"id": "hound-of-baskervilles", "titel": "Der Hund von Baskerville", "originaltitel": "The Hound of the Baskervilles", "jahr": 1939, "genre": "Krimi/Thriller", "typ": "film", "hund_rasse": "Fantastisches Wesen", "stirbt_der_hund": False, "beschreibung": "Basil Rathbone als Sherlock Holmes in der besten Verfilmung des Doyle-Klassikers — die unheilvolle Legende des Dartmoor-Hundes.", "bild_emoji": "🔦", "imdb_rating": 7.4},
|
||||
# ── Neue Einträge: Japan/International ───────────────────────────
|
||||
{"id": "mari-to-koinu", "titel": "Mari und ihr Hundewelpe", "originaltitel": "Mari to koinu no monogatari", "jahr": 2007, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Shiba Inu", "stirbt_der_hund": False, "beschreibung": "Wahre Geschichte: Eine Shiba-Inu-Mutter rettet nach einem Erdbeben in den japanischen Alpen ihr Herrchen und ihre Welpen.", "bild_emoji": "🏔️", "imdb_rating": 7.2},
|
||||
{"id": "ginga-nagareboshi-gin", "titel": "Ginga: Nagareboshi Gin", "originaltitel": "Ginga: Nagareboshi Gin", "jahr": 1986, "genre": "Anime/Abenteuer", "typ": "serie", "hund_rasse": "Akita Inu", "stirbt_der_hund": False, "beschreibung": "Legendärer japanischer Anime: Silber, ein junger Akita, kämpft gegen einen gigantischen Bären — packende Shonen-Klassikerserie der 80er.", "bild_emoji": "⭐", "imdb_rating": 8.0},
|
||||
# ── Neue Einträge: Serien ──────────────────────────────────────────
|
||||
{"id": "dog-whisperer", "titel": "Der Hundeflüsterer", "originaltitel": "Dog Whisperer with Cesar Millan", "jahr": 2004, "genre": "Dokumentation/Reality", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Cesar Millan rehabilitiert Problemhunde und schult deren Besitzer. Legendäre Reality-Serie, die das Hundetraining nachhaltig beeinflusst hat.", "bild_emoji": "🤫", "imdb_rating": 7.8},
|
||||
{"id": "its-me-or-the-dog", "titel": "Ich oder der Hund", "originaltitel": "It's Me or the Dog", "jahr": 2005, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Victoria Stilwell bringt unerzogenen Hunden Manieren bei — mit Positiver Verstärkung statt Dominanztheorie. Britische Erfolgsrealityshow.", "bild_emoji": "💪", "imdb_rating": 7.2},
|
||||
{"id": "lucky-dog", "titel": "Lucky Dog", "originaltitel": "Lucky Dog", "jahr": 2013, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Trainer Brandon McMillan rettet Todestrakt-Hunde aus Tierheimen und trainiert sie innerhalb einer Woche als perfekte Familienhunde.", "bild_emoji": "🌟", "imdb_rating": 7.5},
|
||||
{"id": "dog-impossible", "titel": "Dog: Impossible", "originaltitel": "Dog: Impossible", "jahr": 2019, "genre": "Dokumentation/Reality","typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Matt Beisner arbeitet mit aggressiven und traumatisierten Hunden, die andere aufgegeben haben. National Geographic's bewegende Trainerserie.", "bild_emoji": "🙏", "imdb_rating": 8.1},
|
||||
{"id": "belle-sebastian-anime", "titel": "Belle und Sebastian (Anime)", "originaltitel": "Belle et Sébastien", "jahr": 1984, "genre": "Anime/Familie", "typ": "serie", "hund_rasse": "Pyrenäen-Berghund", "stirbt_der_hund": False, "beschreibung": "Japanische Zeichentrickserie der NHK: Sébastien und sein riesiger weißer Hund Belle in den Alpen — eine Lieblingsserie mehrerer Generationen.", "bild_emoji": "⛰️", "imdb_rating": 7.5},
|
||||
{"id": "the-dog-house", "titel": "The Dog House", "originaltitel": "The Dog House", "jahr": 2019, "genre": "Dokumentation", "typ": "serie", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Wood Green Animal Shelter: Zuschauer beobachten, wie Hunde und Menschen füreinander bestimmt werden. Britische Kultdoku.", "bild_emoji": "🏡", "imdb_rating": 8.2},
|
||||
{"id": "puppy-dog-pals", "titel": "Puppy Dog Pals", "originaltitel": "Puppy Dog Pals", "jahr": 2017, "genre": "Animation/Kinder", "typ": "serie", "hund_rasse": "Mops", "stirbt_der_hund": False, "beschreibung": "Disney Junior-Serie: Zwei Möpse erleben täglich Abenteuer in der Nachbarschaft, während ihr Herrchen weg ist. Ideal für Kleinkinder.", "bild_emoji": "🐾", "imdb_rating": 7.4, "streaming": "Disney+"},
|
||||
{"id": "dogs-of-berlin", "titel": "Dogs of Berlin", "jahr": 2018, "genre": "Krimi/Drama", "typ": "serie", "hund_rasse": "Kampfhund", "stirbt_der_hund": False, "beschreibung": "Netflix Deutschland-Originalserie: Zwei Berliner Ermittler aus verschiedenen Welten jagen einen Mörder — düster, social, brutal ehrlich.", "bild_emoji": "🐕", "imdb_rating": 7.4, "streaming": "Netflix"},
|
||||
# ── Neue Einträge: Dokumentationen ────────────────────────────────
|
||||
{"id": "the-champions-2015", "titel": "The Champions", "jahr": 2015, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Bewegende Doku über die Pitbulls von Michael Vick's Dogfighting-Ring — und ihre erstaunliche Rehabilitation durch engagierte Retter.", "bild_emoji": "💪", "imdb_rating": 7.8},
|
||||
{"id": "one-nation-under-dog", "titel": "One Nation Under Dog", "originaltitel": "One Nation Under Dog", "jahr": 2012, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "HBO-Dokumentation über die komplexe Beziehung der Amerikaner zu Hunden — von Tierheimen bis Luxus-Hundesalons.", "bild_emoji": "🇺🇸", "imdb_rating": 7.4},
|
||||
{"id": "dogs-on-the-inside", "titel": "Dogs on the Inside", "originaltitel": "Dogs on the Inside", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Gefangene in einem Hochsicherheitsgefängnis trainieren Tierheim-Hunde — eine Geschichte über Mitgefühl, Verantwortung und zweite Chancen.", "bild_emoji": "🔒", "imdb_rating": 7.6},
|
||||
{"id": "wonderdog-2023", "titel": "Wonderdog", "originaltitel": "Wonderdog", "jahr": 2023, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Netflix-Doku über die außergewöhnlichen Fähigkeiten von Hunden: Was können sie wirklich riechen, hören und fühlen? Wissenschaft trifft Herz.", "bild_emoji": "🔬", "imdb_rating": 7.1, "streaming": "Netflix"},
|
||||
{"id": "the-supervet", "titel": "The Supervet", "originaltitel": "The Supervet", "jahr": 2014, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Channel 4-Doku über Noel Fitzpatrick, den visionären Veterinärchirurgen, der unheilbar verletzte Tiere mit Hightech-Prothesen rettet.", "bild_emoji": "🦾", "imdb_rating": 8.7},
|
||||
{"id": "off-the-chain", "titel": "Off the Chain", "originaltitel": "Off the Chain", "jahr": 2004, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Pit Bull", "stirbt_der_hund": False, "beschreibung": "Erschütternde Doku über Pit Bulls in Amerika — zwischen Missbrauch, Dogfighting-Kultur und den Menschen, die für sie kämpfen.", "bild_emoji": "⛓️", "imdb_rating": 7.2},
|
||||
{"id": "war-dog-soldier", "titel": "War Dog: A Soldier's Best Friend", "originaltitel": "War Dog: A Soldier's Best Friend", "jahr": 2017, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "National Geographic-Doku über Militärhunde und die unzerstörbare Bindung zu ihren Hundeführern — von der Ausbildung bis zum Einsatz.", "bild_emoji": "🎖️", "imdb_rating": 7.8},
|
||||
]
|
||||
|
||||
_SEED_PROMIS = [
|
||||
|
|
@ -176,25 +111,27 @@ _SEED_PROMIS = [
|
|||
|
||||
|
||||
def seed_movies():
|
||||
"""Füllt die movies-Tabelle mit allen Seed-Einträgen (idempotent per INSERT OR IGNORE)."""
|
||||
"""Füllt die movies-Tabelle beim ersten Start (idempotent per INSERT OR IGNORE)."""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
with db() as conn:
|
||||
for i, f in enumerate(_SEED_FILME):
|
||||
conn.execute("""
|
||||
INSERT OR IGNORE INTO movies
|
||||
(id, titel, originaltitel, jahr, genre, typ, hund_rasse,
|
||||
stirbt_der_hund, beschreibung, bild_emoji, imdb_rating,
|
||||
streaming, sort_order)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
f["id"], f["titel"], f.get("originaltitel"),
|
||||
f.get("jahr"), f.get("genre"), f.get("typ", "film"),
|
||||
f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0,
|
||||
f.get("beschreibung"), f.get("bild_emoji", "🐾"),
|
||||
f.get("imdb_rating"), f.get("streaming"), i,
|
||||
))
|
||||
logger.info(f"movies: seed_movies() ausgeführt, {len(_SEED_FILME)} Einträge in der Liste.")
|
||||
count = conn.execute("SELECT COUNT(*) FROM movies").fetchone()[0]
|
||||
if count == 0:
|
||||
for i, f in enumerate(_SEED_FILME):
|
||||
conn.execute("""
|
||||
INSERT OR IGNORE INTO movies
|
||||
(id, titel, originaltitel, jahr, genre, typ, hund_rasse,
|
||||
stirbt_der_hund, beschreibung, bild_emoji, imdb_rating,
|
||||
streaming, sort_order)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
f["id"], f["titel"], f.get("originaltitel"),
|
||||
f.get("jahr"), f.get("genre"), f.get("typ", "film"),
|
||||
f.get("hund_rasse"), 1 if f.get("stirbt_der_hund") else 0,
|
||||
f.get("beschreibung"), f.get("bild_emoji", "🐾"),
|
||||
f.get("imdb_rating"), f.get("streaming"), i,
|
||||
))
|
||||
logger.info(f"movies: {len(_SEED_FILME)} Filme geseedet.")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -243,7 +180,7 @@ _SORT_COLS = {
|
|||
"jahr_asc": "m.jahr ASC",
|
||||
"imdb": "m.imdb_rating DESC",
|
||||
"bewertung": "community_avg DESC",
|
||||
"default": "CASE WHEN m.imdb_rating IS NULL THEN 1 ELSE 0 END, m.imdb_rating DESC, m.jahr DESC",
|
||||
"default": "m.sort_order ASC, m.jahr DESC",
|
||||
}
|
||||
|
||||
@router.get("/filme")
|
||||
|
|
@ -386,34 +323,6 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)):
|
|||
return {"monat": monat, "top": [dict(r) for r in rows], "user_vote": user_vote}
|
||||
|
||||
|
||||
@router.get("/hund-des-monats/kandidaten")
|
||||
async def get_hdm_kandidaten(user=Depends(get_current_user)):
|
||||
"""Alle öffentlichen Hunde anderer User, mit aktuellem Stimmenstand."""
|
||||
monat = datetime.now().strftime("%Y-%m")
|
||||
with db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT d.id, d.name, d.rasse, d.foto_url,
|
||||
u.name AS besitzer_name,
|
||||
COALESCE(v.stimmen, 0) AS stimmen
|
||||
FROM dogs d
|
||||
JOIN users u ON u.id = d.user_id
|
||||
LEFT JOIN (
|
||||
SELECT dog_id, COUNT(*) AS stimmen
|
||||
FROM hund_des_monats_votes
|
||||
WHERE monat = ?
|
||||
GROUP BY dog_id
|
||||
) v ON v.dog_id = d.id
|
||||
WHERE d.is_public = 1
|
||||
AND d.user_id != ?
|
||||
ORDER BY
|
||||
CASE WHEN d.foto_url IS NOT NULL THEN 0 ELSE 1 END,
|
||||
stimmen DESC,
|
||||
d.name ASC
|
||||
LIMIT 60
|
||||
""", (monat, user["id"])).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/hund-des-monats/vote")
|
||||
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
|
||||
monat = datetime.now().strftime("%Y-%m")
|
||||
|
|
@ -421,9 +330,7 @@ async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_
|
|||
dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone()
|
||||
if not dog:
|
||||
raise HTTPException(404, "Hund nicht gefunden.")
|
||||
if dog["user_id"] == user["id"]:
|
||||
raise HTTPException(403, "Du kannst nicht für deinen eigenen Hund abstimmen.")
|
||||
if not dog["is_public"]:
|
||||
if dog["user_id"] != user["id"] and not dog["is_public"]:
|
||||
raise HTTPException(403, "Dieser Hund ist nicht öffentlich.")
|
||||
conn.execute("""
|
||||
INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import smtplib
|
|||
import ssl
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.utils import formataddr, formatdate
|
||||
from email.utils import formataddr
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
|
|
@ -87,7 +87,6 @@ def _imap_save_sent(msg_bytes: bytes, account: str):
|
|||
def _build_message(to: str, subject: str, body: str, account: str, html: str = None) -> MIMEMultipart:
|
||||
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Date"] = formatdate(localtime=False) # UTC explizit, Container hat keine lokale TZ
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = formataddr((acc["name"], acc["from"]))
|
||||
msg["To"] = to
|
||||
|
|
@ -98,22 +97,11 @@ def _build_message(to: str, subject: str, body: str, account: str, html: str = N
|
|||
return msg
|
||||
|
||||
|
||||
_LEGAL_FOOTER = (
|
||||
"\n\n---\n"
|
||||
"Ban Yaro | René Degelmann | Ringstr. 26, D-85560 Ebersberg\n"
|
||||
"Web: https://banyaro.app | Mail: partner@banyaro.app\n\n"
|
||||
"Datenschutzhinweis: Deine Kontaktdaten stammen aus deinem öffentlichen Profil. "
|
||||
"Verarbeitung auf Basis berechtigten Interesses (Art. 6 Abs. 1 lit. f DSGVO). "
|
||||
"Datenschutzerklärung: https://banyaro.app/datenschutz\n"
|
||||
"Widerspruch/Löschung: Einfach auf diese Mail antworten."
|
||||
)
|
||||
|
||||
|
||||
def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html: str = None):
|
||||
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
|
||||
if not acc["user"] or not acc["pass"]:
|
||||
raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.")
|
||||
msg = _build_message(to, subject, body + _LEGAL_FOOTER, account, html=html)
|
||||
msg = _build_message(to, subject, body, account, html=html)
|
||||
msg_bytes = msg.as_bytes()
|
||||
ctx = ssl.create_default_context()
|
||||
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s:
|
||||
|
|
@ -267,7 +255,7 @@ def send_support_mail(to: str, subject: str, body: str):
|
|||
def outreach_log_endpoint(user=Depends(require_admin)):
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT ol.id, ol.recipient, ol.subject, ol.body, ol.sent_at,
|
||||
"""SELECT ol.id, ol.recipient, ol.subject, ol.sent_at,
|
||||
ol.from_account, u.name AS sent_by_name
|
||||
FROM outreach_log ol
|
||||
JOIN users u ON u.id = ol.sent_by
|
||||
|
|
|
|||
|
|
@ -694,12 +694,11 @@ async def list_zuchter_pending(user=Depends(get_current_user)):
|
|||
raise HTTPException(403, "Nur Moderatoren.")
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT z.*, u.name AS user_name, m.name AS verified_by_name
|
||||
"""SELECT z.*, u.name AS user_name
|
||||
FROM wiki_zuchter z
|
||||
LEFT JOIN users u ON u.id = z.user_id
|
||||
LEFT JOIN users m ON m.id = z.verified_by
|
||||
ORDER BY z.verified ASC, z.created_at ASC
|
||||
LIMIT 200""",
|
||||
WHERE z.verified=0
|
||||
ORDER BY z.created_at ASC""",
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
|
@ -717,10 +716,8 @@ async def verify_zuchter(zuchter_id: int, user=Depends(get_current_user)):
|
|||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Züchter nicht gefunden.")
|
||||
from datetime import datetime
|
||||
conn.execute(
|
||||
"UPDATE wiki_zuchter SET verified=1, verified_by=?, verified_at=? WHERE id=?",
|
||||
(user["id"], datetime.utcnow().isoformat(), zuchter_id)
|
||||
"UPDATE wiki_zuchter SET verified=1 WHERE id=?", (zuchter_id,)
|
||||
)
|
||||
result = conn.execute(
|
||||
"SELECT * FROM wiki_zuchter WHERE id=?", (zuchter_id,)
|
||||
|
|
|
|||
|
|
@ -100,14 +100,6 @@ def start():
|
|||
replace_existing=True,
|
||||
misfire_grace_time=1800,
|
||||
)
|
||||
# Täglich 12:00 — Moderation-Overdue-Check
|
||||
_scheduler.add_job(
|
||||
_job_moderation_overdue,
|
||||
CronTrigger(hour=12, minute=0),
|
||||
id="moderation_overdue",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=1800,
|
||||
)
|
||||
# 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht
|
||||
_scheduler.add_job(
|
||||
_job_quarterly_report,
|
||||
|
|
@ -124,16 +116,8 @@ def start():
|
|||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
# 1. des Monats 00:05 — Hund des Monats Sieger festlegen
|
||||
_scheduler.add_job(
|
||||
_job_hdm_winner,
|
||||
CronTrigger(day=1, hour=0, minute=5),
|
||||
id="hdm_winner",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
_scheduler.start()
|
||||
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).")
|
||||
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00. OSM-Cache: on-demand (kein Prewarm).")
|
||||
|
||||
|
||||
def stop():
|
||||
|
|
@ -666,115 +650,6 @@ async def _job_ki_health_report():
|
|||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
async def _job_moderation_overdue():
|
||||
"""Sendet Alarm-Mail wenn Moderations-Einträge seit >24h offen sind."""
|
||||
import os
|
||||
from mailer import send_email
|
||||
|
||||
admin = os.getenv("ADMIN_EMAIL", "")
|
||||
if not admin:
|
||||
return
|
||||
|
||||
SLA_H = 24
|
||||
threshold = f"datetime('now', '-{SLA_H} hours')"
|
||||
|
||||
overdue = {}
|
||||
try:
|
||||
with db() as conn:
|
||||
n = conn.execute(f"SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing') AND created_at < {threshold}").fetchone()[0]
|
||||
if n: overdue["Bewerbungen"] = n
|
||||
n = conn.execute(f"SELECT COUNT(*) FROM users WHERE breeder_status='pending' AND created_at < {threshold}").fetchone()[0]
|
||||
if n: overdue["Züchter-Anträge"] = n
|
||||
n = conn.execute(f"SELECT COUNT(*) FROM forum_reports WHERE resolved=0 AND created_at < {threshold}").fetchone()[0]
|
||||
if n: overdue["Forum-Meldungen"] = n
|
||||
n = conn.execute(f"SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending' AND created_at < {threshold}").fetchone()[0]
|
||||
if n: overdue["Foto-Einreichungen"] = n
|
||||
n = conn.execute(f"SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending' AND created_at < {threshold}").fetchone()[0]
|
||||
if n: overdue["POI-Korrekturen"] = n
|
||||
n = conn.execute(f"SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0 AND created_at < {threshold}").fetchone()[0]
|
||||
if n: overdue["Züchter-Einreichungen (Wiki)"] = n
|
||||
except Exception as e:
|
||||
logger.error(f"Moderation-Overdue-Check: DB-Fehler: {e}")
|
||||
return
|
||||
|
||||
if not overdue:
|
||||
logger.info("Moderation-Overdue-Check: Alles im SLA.")
|
||||
return
|
||||
|
||||
now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y %H:%M")
|
||||
rows_html = "".join(
|
||||
f'<tr><td style="padding:6px 12px;font-weight:600;color:#c45000">{label}</td>'
|
||||
f'<td style="padding:6px 12px;font-size:18px;font-weight:800;color:#c45000">{count}</td></tr>'
|
||||
for label, count in overdue.items()
|
||||
)
|
||||
html = f"""\
|
||||
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"></head>
|
||||
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
|
||||
<div style="max-width:560px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
|
||||
<div style="background:linear-gradient(135deg,#c45000,#e8733a);padding:22px 28px;color:#fff">
|
||||
<div style="font-size:20px;font-weight:800;margin-bottom:2px">⚠️ Moderation überfällig</div>
|
||||
<div style="opacity:.88;font-size:13px">{now_str} · SLA: {SLA_H}h</div>
|
||||
</div>
|
||||
<div style="padding:22px 28px">
|
||||
<p style="color:#444;margin:0 0 16px">Folgende Einträge warten seit mehr als {SLA_H} Stunden auf Bearbeitung:</p>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:14px">
|
||||
<thead><tr style="border-bottom:2px solid #f0e8dc">
|
||||
<th style="text-align:left;padding:6px 12px;color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.06em">Bereich</th>
|
||||
<th style="text-align:left;padding:6px 12px;color:#888;font-size:11px;text-transform:uppercase;letter-spacing:.06em">Anzahl</th>
|
||||
</tr></thead>
|
||||
<tbody>{rows_html}</tbody>
|
||||
</table>
|
||||
<div style="margin-top:20px">
|
||||
<a href="https://banyaro.app/app/admin" style="display:inline-block;background:#c45000;color:#fff;
|
||||
text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700;font-size:14px">
|
||||
→ Admin-Panel öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:12px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
|
||||
Ban Yaro · banyaro.app
|
||||
</div>
|
||||
</div></body></html>"""
|
||||
|
||||
plain = f"Ban Yaro — Moderation überfällig ({now_str})\n\nSeit >{SLA_H}h offen:\n" + \
|
||||
"\n".join(f" • {l}: {c}" for l, c in overdue.items()) + \
|
||||
"\n\nhttps://banyaro.app/app/admin"
|
||||
|
||||
try:
|
||||
await send_email(admin, f"⚠️ Ban Yaro — Moderation überfällig ({', '.join(overdue)})", html, plain)
|
||||
logger.info(f"Moderation-Overdue-Mail gesendet: {overdue}")
|
||||
except Exception as e:
|
||||
logger.error(f"Moderation-Overdue-Mail fehlgeschlagen: {e}")
|
||||
|
||||
|
||||
def _action_items_html(metrics: dict) -> str:
|
||||
items = [
|
||||
("jobs_pending", "Bewerbungen offen"),
|
||||
("breeder_pending", "Züchter-Anträge"),
|
||||
("reports_open", "Forum-Meldungen"),
|
||||
("fotos_pending", "Foto-Einreichungen"),
|
||||
("poi_edits_pending", "POI-Korrekturen"),
|
||||
]
|
||||
open_items = [(label, metrics.get(key, 0)) for key, label in items if metrics.get(key, 0) > 0]
|
||||
|
||||
if not open_items:
|
||||
body = '<span style="color:#16a34a;font-weight:700">✅ Alles erledigt — nichts offen</span>'
|
||||
else:
|
||||
pills = "".join(
|
||||
f'<span style="display:inline-block;background:#fff3e0;color:#c45000;border:1px solid #e8a857;'
|
||||
f'border-radius:999px;padding:3px 12px;font-size:12px;font-weight:700;margin:2px 4px 2px 0">'
|
||||
f'{label} <strong style="background:#c45000;color:#fff;border-radius:999px;'
|
||||
f'padding:0 7px;margin-left:4px">{count}</strong></span>'
|
||||
for label, count in open_items
|
||||
)
|
||||
body = f'<div style="font-size:13px;font-weight:600;color:#c45000;margin-bottom:8px">⚠️ {len(open_items)} Punkt{"e" if len(open_items)!=1 else ""} brauchen deine Aufmerksamkeit</div>{pills}'
|
||||
|
||||
link = '<div style="margin-top:10px"><a href="https://banyaro.app/app/admin" style="font-size:12px;color:#C4843A">→ Admin-Panel öffnen</a></div>'
|
||||
return f'<div style="padding:20px 28px;border-bottom:2px solid #e8a857;background:#fffbf5">' \
|
||||
f'<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Heute zu erledigen</div>' \
|
||||
f'{body}{link}</div>'
|
||||
|
||||
|
||||
# JOB: Status-Report per Mail (täglich 06:00 Uhr)
|
||||
# ------------------------------------------------------------------
|
||||
async def _job_status_report():
|
||||
|
|
@ -802,7 +677,6 @@ async def _job_status_report():
|
|||
|
||||
# Community
|
||||
metrics["users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
metrics["users_today"] = conn.execute("SELECT COUNT(*) FROM users WHERE DATE(created_at)=DATE('now')").fetchone()[0]
|
||||
metrics["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
|
||||
metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0]
|
||||
metrics["poison_active"] = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0").fetchone()[0]
|
||||
|
|
@ -811,28 +685,6 @@ async def _job_status_report():
|
|||
except Exception:
|
||||
metrics["lost_active"] = 0
|
||||
|
||||
# Action Items
|
||||
try:
|
||||
metrics["jobs_pending"] = conn.execute("SELECT COUNT(*) FROM job_applications WHERE status IN ('pending','reviewing')").fetchone()[0]
|
||||
except Exception:
|
||||
metrics["jobs_pending"] = 0
|
||||
try:
|
||||
metrics["breeder_pending"] = conn.execute("SELECT COUNT(*) FROM users WHERE breeder_status='pending'").fetchone()[0]
|
||||
except Exception:
|
||||
metrics["breeder_pending"] = 0
|
||||
try:
|
||||
metrics["reports_open"] = conn.execute("SELECT COUNT(*) FROM forum_reports WHERE resolved=0").fetchone()[0]
|
||||
except Exception:
|
||||
metrics["reports_open"] = 0
|
||||
try:
|
||||
metrics["fotos_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_foto_submissions WHERE status='pending'").fetchone()[0]
|
||||
except Exception:
|
||||
metrics["fotos_pending"] = 0
|
||||
try:
|
||||
metrics["poi_edits_pending"] = conn.execute("SELECT COUNT(*) FROM osm_poi_edits WHERE status='pending'").fetchone()[0]
|
||||
except Exception:
|
||||
metrics["poi_edits_pending"] = 0
|
||||
|
||||
# Wiki-Interesse
|
||||
try:
|
||||
metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0]
|
||||
|
|
@ -884,9 +736,6 @@ async def _job_status_report():
|
|||
<div style="opacity:.88;font-size:13px">{now_str} Uhr</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Items -->
|
||||
{_action_items_html(metrics)}
|
||||
|
||||
<!-- Scheduler-Status -->
|
||||
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
|
||||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Scheduler-Jobs</div>
|
||||
|
|
@ -900,14 +749,14 @@ async def _job_status_report():
|
|||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Community</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
{"".join(f'<div style="background:#fdf6ef;border-radius:8px;padding:10px 14px"><div style="font-size:20px;font-weight:800;color:#C4843A">{v}</div><div style="font-size:11px;color:#888">{k}</div></div>' for k,v in [
|
||||
("Nutzer gesamt",metrics["users"]),
|
||||
("Neue Nutzer heute",metrics["users_today"]),
|
||||
("Nutzer",metrics["users"]),
|
||||
("Hunde",metrics["dogs"]),
|
||||
("Tagebuch-Einträge",metrics["diary_entries"]),
|
||||
("Aktive Giftköder",metrics["poison_active"]),
|
||||
("Vermisste Hunde",metrics["lost_active"]),
|
||||
("'So einen hab ich'",metrics["interesse_hat"]),
|
||||
("'Interessiert mich'",metrics["interesse_will"]),
|
||||
("Züchter (pending)",metrics["zuchter_pending"]),
|
||||
])}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -921,28 +770,19 @@ async def _job_status_report():
|
|||
</body>
|
||||
</html>"""
|
||||
|
||||
action_open = [l for k,l in [
|
||||
("jobs_pending","Bewerbungen"),("breeder_pending","Züchter-Anträge"),
|
||||
("reports_open","Meldungen"),("fotos_pending","Fotos"),("poi_edits_pending","POI-Korrekturen"),
|
||||
] if metrics.get(k,0) > 0]
|
||||
plain = f"""Ban Yaro Status-Report — {now_str}
|
||||
|
||||
=== HEUTE ZU ERLEDIGEN ===
|
||||
{"✅ Alles erledigt" if not action_open else "⚠️ " + ", ".join(f"{l} ({metrics[k]})" for k,l in [
|
||||
("jobs_pending","Bewerbungen"),("breeder_pending","Züchter-Anträge"),
|
||||
("reports_open","Meldungen"),("fotos_pending","Fotos"),("poi_edits_pending","POI-Korrekturen"),
|
||||
] if metrics.get(k,0) > 0)}
|
||||
|
||||
=== Scheduler-Jobs ===
|
||||
{job_rows_txt}
|
||||
=== Community ===
|
||||
Nutzer gesamt: {metrics['users']} (+{metrics['users_today']} heute)
|
||||
Nutzer: {metrics['users']}
|
||||
Hunde: {metrics['dogs']}
|
||||
Tagebuch-Einträge: {metrics['diary_entries']}
|
||||
Aktive Giftköder: {metrics['poison_active']}
|
||||
Vermisste Hunde: {metrics['lost_active']}
|
||||
'So einen hab ich': {metrics['interesse_hat']}
|
||||
'Interessiert mich': {metrics['interesse_will']}
|
||||
Züchter (pending): {metrics['zuchter_pending']}
|
||||
"""
|
||||
|
||||
try:
|
||||
|
|
@ -1118,57 +958,3 @@ def _compute_milestone(today: date, bday: date, dog_name: str):
|
|||
return titel, text
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JOB: Hund des Monats — Sieger des Vormonats festlegen
|
||||
# ------------------------------------------------------------------
|
||||
async def _job_hdm_winner():
|
||||
"""Läuft am 1. des Monats 00:05 und schreibt den Sieger des Vormonats."""
|
||||
today = datetime.now(tz=_TZ)
|
||||
# Vormonat berechnen
|
||||
first_this = today.replace(day=1)
|
||||
last_month = (first_this - timedelta(days=1)).replace(day=1)
|
||||
monat = last_month.strftime("%Y-%m")
|
||||
|
||||
with db() as conn:
|
||||
# Schon eingetragen?
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM hund_des_monats_wins WHERE monat=?", (monat,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
logger.info(f"HdM-Winner {monat}: bereits eingetragen, übersprungen.")
|
||||
_log_job("hdm_winner", "ok", f"bereits vorhanden für {monat}")
|
||||
return
|
||||
|
||||
winner = conn.execute("""
|
||||
SELECT v.dog_id, d.name, d.user_id, COUNT(v.id) AS stimmen
|
||||
FROM hund_des_monats_votes v
|
||||
JOIN dogs d ON d.id = v.dog_id
|
||||
WHERE v.monat = ?
|
||||
GROUP BY v.dog_id
|
||||
ORDER BY stimmen DESC
|
||||
LIMIT 1
|
||||
""", (monat,)).fetchone()
|
||||
|
||||
if not winner:
|
||||
logger.info(f"HdM-Winner {monat}: keine Stimmen, kein Sieger.")
|
||||
_log_job("hdm_winner", "ok", f"keine Stimmen für {monat}")
|
||||
return
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO hund_des_monats_wins (dog_id, monat, stimmen) VALUES (?, ?, ?)",
|
||||
(winner["dog_id"], monat, winner["stimmen"]),
|
||||
)
|
||||
|
||||
month_label = last_month.strftime("%B %Y")
|
||||
send_push_to_user(winner["user_id"], {
|
||||
"type": "hdm_winner",
|
||||
"title": f"🏆 {winner['name']} ist Hund des Monats!",
|
||||
"body": f"{winner['name']} hat den {month_label} gewonnen — herzlichen Glückwunsch!",
|
||||
"data": {"page": "forum"},
|
||||
"tag": f"hdm-{monat}",
|
||||
})
|
||||
|
||||
logger.info(f"HdM-Winner {monat}: Hund {winner['dog_id']} ('{winner['name']}', {winner['stimmen']} Stimmen) eingetragen.")
|
||||
_log_job("hdm_winner", "ok", f"{monat}: {winner['name']} ({winner['stimmen']} Stimmen)")
|
||||
|
|
|
|||
|
|
@ -435,7 +435,7 @@
|
|||
.form-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--c-text);
|
||||
color: var(--c-text-secondary);
|
||||
}
|
||||
|
||||
/* iOS Safari: font-size < 16px triggert Auto-Zoom beim Fokus — muss alle Klassen überschreiben */
|
||||
|
|
@ -4179,19 +4179,6 @@ html.modal-open {
|
|||
text-overflow: ellipsis;
|
||||
max-width: 10rem; /* prevents single pill from being wider than ~160px on mobile */
|
||||
}
|
||||
.by-tab-text {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.by-tab-text.scrolling {
|
||||
animation: forum-tab-scroll 1.8s ease-in-out 0.3s infinite alternate;
|
||||
transition: none;
|
||||
}
|
||||
@keyframes forum-tab-scroll {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(var(--tab-scroll-px, 0)); }
|
||||
}
|
||||
|
||||
/* Category badge (colored pill) */
|
||||
.forum-category-badge {
|
||||
|
|
@ -4213,59 +4200,6 @@ html.modal-open {
|
|||
.forum-category-badge--tauschboerse { background: #fce7f3; color: #9d174d; }
|
||||
|
||||
/* Search */
|
||||
/* Hund des Monats — kompakte Forum-Kachel */
|
||||
.forum-hdm-tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: linear-gradient(135deg, var(--c-surface-2) 0%, var(--c-bg) 100%);
|
||||
border: 1.5px solid var(--c-border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-3);
|
||||
min-width: 0;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.forum-hdm-tile:hover { border-color: var(--c-primary); box-shadow: var(--shadow-sm); }
|
||||
.forum-hdm-tile-trophy { font-size: 1.5rem; flex-shrink: 0; }
|
||||
.forum-hdm-tile-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.forum-hdm-tile-title {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--c-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.forum-hdm-tile-winner {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--c-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.forum-hdm-tile-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--c-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.forum-hdm-tile-cta {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--c-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.forum-search-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -4984,25 +4918,6 @@ html.modal-open {
|
|||
}
|
||||
|
||||
/* Filter-Row */
|
||||
.movies-search-row {
|
||||
position: relative;
|
||||
padding: var(--space-3) 0 var(--space-1);
|
||||
}
|
||||
.movies-search-icon {
|
||||
position: absolute;
|
||||
left: var(--space-3);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--c-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.movies-search-input {
|
||||
padding-left: calc(var(--space-3) + 16px + var(--space-2)) !important;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.movies-filter-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
|
|
@ -5241,19 +5156,11 @@ html.modal-open {
|
|||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
/* Kandidaten-Suche */
|
||||
.hdm-kandidaten-search {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
/* Vote-Grid */
|
||||
.hdm-vote-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-3);
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
padding-right: var(--space-1);
|
||||
}
|
||||
|
||||
.hdm-vote-card {
|
||||
|
|
@ -6065,21 +5972,6 @@ html.modal-open {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hund des Monats — Profil-Badge */
|
||||
.dp-hdm-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||
color: #78350f;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: .02em;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.15);
|
||||
}
|
||||
|
||||
/* --- Foto-Editor Modal --- */
|
||||
.photo-editor { display: flex; flex-direction: column; gap: var(--space-3); align-items: center; }
|
||||
.photo-editor-preview {
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 504 KiB |
|
|
@ -90,7 +90,7 @@
|
|||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=545">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=545">
|
||||
<link rel="stylesheet" href="/css/components.css?v=546">
|
||||
<link rel="stylesheet" href="/css/components.css?v=545">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '597'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '583'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
|
||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||
|
||||
|
|
|
|||
|
|
@ -48,9 +48,6 @@ window.Page_admin = (() => {
|
|||
// ------------------------------------------------------------------
|
||||
function _render() {
|
||||
_container.innerHTML = `
|
||||
<!-- Action Items -->
|
||||
<div id="adm-action-items" style="padding:var(--space-3) var(--space-3) 0"></div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="by-tabs adm-tabs" id="adm-tabs">
|
||||
${TABS.map(t => `
|
||||
|
|
@ -76,68 +73,9 @@ window.Page_admin = (() => {
|
|||
});
|
||||
});
|
||||
|
||||
_renderActionItems();
|
||||
_renderTab();
|
||||
}
|
||||
|
||||
async function _renderActionItems() {
|
||||
const el = _container.querySelector('#adm-action-items');
|
||||
if (!el) return;
|
||||
let d;
|
||||
try { d = await API.get('/admin/action-items'); } catch { return; }
|
||||
|
||||
const items = [
|
||||
{ key: 'jobs_pending', label: 'Bewerbungen', tab: 'bewerbungen', icon: 'user-plus' },
|
||||
{ key: 'breeder_pending', label: 'Züchter-Anträge', tab: 'zuchter', icon: 'certificate' },
|
||||
{ key: 'reports_open', label: 'Meldungen', tab: 'moderation', icon: 'warning' },
|
||||
{ key: 'fotos_pending', label: 'Foto-Einreichungen',tab: 'moderation', icon: 'image' },
|
||||
{ key: 'poi_edits_pending', label: 'POI-Korrekturen', tab: 'moderation', icon: 'map-pin' },
|
||||
];
|
||||
|
||||
const open = items.filter(i => d[i.key] > 0);
|
||||
const usersToday = d.users_today || 0;
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
|
||||
background:var(--c-surface);border:1px solid var(--c-border);
|
||||
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4)">
|
||||
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
|
||||
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
|
||||
${UI.icon('check-square')} Zu erledigen
|
||||
</span>
|
||||
${open.length === 0
|
||||
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
|
||||
${UI.icon('check-circle')} Alles erledigt
|
||||
</span>`
|
||||
: open.map(i => `
|
||||
<button data-action-tab="${i.tab}"
|
||||
style="display:inline-flex;align-items:center;gap:4px;
|
||||
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
|
||||
border:1px solid var(--c-warning,#e65100);border-radius:999px;
|
||||
padding:2px 10px;font-size:var(--text-xs);font-weight:700;cursor:pointer">
|
||||
${UI.icon(i.icon)} ${i.label}
|
||||
<span style="background:var(--c-warning,#e65100);color:#fff;
|
||||
border-radius:999px;padding:0 6px;margin-left:2px">
|
||||
${d[i.key]}
|
||||
</span>
|
||||
</button>`).join('')
|
||||
}
|
||||
<span style="margin-left:auto;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
${UI.icon('user-plus')} ${usersToday} neue Nutzer heute
|
||||
</span>
|
||||
</div>`;
|
||||
|
||||
el.querySelectorAll('[data-action-tab]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_tab = btn.dataset.actionTab;
|
||||
_container.querySelectorAll('#adm-tabs .by-tab').forEach(b =>
|
||||
b.classList.toggle('active', b.dataset.tab === _tab)
|
||||
);
|
||||
_renderTab();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function _renderTab() {
|
||||
const el = _container.querySelector('#adm-content');
|
||||
if (!el) return;
|
||||
|
|
@ -1460,43 +1398,6 @@ window.Page_admin = (() => {
|
|||
// ------------------------------------------------------------------
|
||||
// TAB: MODERATION
|
||||
// ------------------------------------------------------------------
|
||||
function _ageLabel(createdAt) {
|
||||
if (!createdAt) return '';
|
||||
const h = (Date.now() - new Date(createdAt + 'Z').getTime()) / 3600000;
|
||||
const overdue = h >= 24;
|
||||
const label = h < 1 ? '<1h' : h < 24 ? `${Math.floor(h)}h` : `${Math.floor(h/24)}d ${Math.floor(h%24)}h`;
|
||||
return `<span style="font-size:var(--text-xs);font-weight:700;padding:1px 7px;border-radius:999px;
|
||||
margin-left:6px;${overdue
|
||||
? 'background:#fef2f2;color:#dc2626;border:1px solid #fca5a5'
|
||||
: 'background:var(--c-surface-2);color:var(--c-text-muted);border:1px solid var(--c-border)'}">
|
||||
${overdue ? '⚠️ ' : ''}${label}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function _historySection(label, items, renderItem) {
|
||||
const id = `hist-${label.replace(/\W/g,'').toLowerCase()}`;
|
||||
return `
|
||||
<details style="margin-bottom:var(--space-4)">
|
||||
<summary style="cursor:pointer;list-style:none;display:flex;align-items:center;gap:var(--space-2);
|
||||
font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
|
||||
text-transform:uppercase;letter-spacing:.06em;padding:var(--space-2) 0;
|
||||
border-top:1px solid var(--c-border)">
|
||||
${UI.icon('clock-countdown')} ${items.length} erledigte ${label}
|
||||
<svg class="ph-icon" style="margin-left:auto;transition:transform .2s" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#caret-down"></use>
|
||||
</svg>
|
||||
</summary>
|
||||
<div style="margin-top:var(--space-2);display:flex;flex-direction:column;gap:var(--space-1)">
|
||||
${items.map(item => `
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||||
border-radius:var(--radius-sm);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
|
||||
${renderItem(item)}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
async function _renderModeration(el) {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||
|
|
@ -1511,52 +1412,12 @@ window.Page_admin = (() => {
|
|||
async function _loadModeration(el) {
|
||||
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||||
|
||||
const [zuchter, fotos, reports, poiEdits] = await Promise.all([
|
||||
const [zuchter, fotos] = await Promise.all([
|
||||
API.get('/wiki/zuchter/pending').catch(() => []),
|
||||
API.get('/wiki/foto-submissions').catch(() => []),
|
||||
API.get('/moderation/reports').catch(() => []),
|
||||
API.get('/moderation/poi-edits').catch(() => []),
|
||||
]);
|
||||
const zuchterPending = zuchter.filter(z => !z.verified);
|
||||
const zuchterDone = zuchter.filter(z => z.verified);
|
||||
const fotosPending = fotos.filter(f => f.status === 'pending');
|
||||
const fotosDone = fotos.filter(f => f.status !== 'pending');
|
||||
const reportsPending = reports.filter(r => !r.resolved);
|
||||
const reportsDone = reports.filter(r => r.resolved);
|
||||
const poiPending = poiEdits.filter(e => e.status === 'pending');
|
||||
const poiDone = poiEdits.filter(e => e.status !== 'pending');
|
||||
|
||||
const modItems = [
|
||||
{ label: 'Züchter-Einreichungen', count: zuchterPending.length, icon: 'certificate' },
|
||||
{ label: 'Foto-Einreichungen', count: fotosPending.length, icon: 'image' },
|
||||
{ label: 'Forum-Meldungen', count: reportsPending.length, icon: 'warning' },
|
||||
{ label: 'POI-Korrekturen', count: poiPending.length, icon: 'map-pin' },
|
||||
].filter(i => i.count > 0);
|
||||
|
||||
let html = `
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);align-items:center;
|
||||
background:var(--c-surface);border:1px solid var(--c-border);
|
||||
border-radius:var(--radius-lg);padding:var(--space-3) var(--space-4);
|
||||
margin-bottom:var(--space-4)">
|
||||
<span style="font-size:var(--text-xs);font-weight:700;color:var(--c-text-muted);
|
||||
text-transform:uppercase;letter-spacing:.06em;margin-right:var(--space-1)">
|
||||
${UI.icon('check-square')} Zu erledigen
|
||||
</span>
|
||||
${modItems.length === 0
|
||||
? `<span style="font-size:var(--text-sm);color:var(--c-success,#4caf50);font-weight:600">
|
||||
${UI.icon('check-circle')} Alles erledigt
|
||||
</span>`
|
||||
: modItems.map(i => `
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;
|
||||
background:var(--c-warning-light,#fff3e0);color:var(--c-warning,#e65100);
|
||||
border:1px solid var(--c-warning,#e65100);border-radius:999px;
|
||||
padding:2px 10px;font-size:var(--text-xs);font-weight:700">
|
||||
${UI.icon(i.icon)} ${i.label}
|
||||
<strong style="background:var(--c-warning,#e65100);color:#fff;
|
||||
border-radius:999px;padding:0 6px;margin-left:2px">${i.count}</strong>
|
||||
</span>`).join('')
|
||||
}
|
||||
</div>`;
|
||||
let html = '';
|
||||
|
||||
// --- Züchter-Einreichungen ---
|
||||
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
|
|
@ -1564,24 +1425,23 @@ window.Page_admin = (() => {
|
|||
margin-bottom:var(--space-3)">
|
||||
Züchter-Einreichungen
|
||||
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
|
||||
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchterPending.length}</span>
|
||||
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${zuchter.length}</span>
|
||||
</h3>`;
|
||||
|
||||
if (!zuchterPending.length) {
|
||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Einreichungen.</p>`;
|
||||
if (!zuchter.length) {
|
||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-6)">Keine ausstehenden Einreichungen.</p>`;
|
||||
} else {
|
||||
html += `<div class="card adm-table-card" style="margin-bottom:var(--space-3)"><div class="adm-table-scroll"><table class="adm-table">
|
||||
html += `<div class="card adm-table-card" style="margin-bottom:var(--space-6)"><div class="adm-table-scroll"><table class="adm-table">
|
||||
<thead><tr style="background:var(--c-surface-2);text-align:left">
|
||||
<th class="adm-th">Rasse</th><th class="adm-th">Name / Zwingername</th>
|
||||
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Alter</th><th class="adm-th">Website</th><th class="adm-th"></th>
|
||||
<th class="adm-th">Ort</th><th class="adm-th">VDH</th><th class="adm-th">Website</th><th class="adm-th"></th>
|
||||
</tr></thead><tbody>
|
||||
${zuchterPending.map((z, i) => `
|
||||
${zuchter.map((z, i) => `
|
||||
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
|
||||
<td class="adm-td" style="font-weight:var(--weight-semibold)">${_esc(z.rasse_slug)}</td>
|
||||
<td class="adm-td">${_esc(z.name)}${z.zwingername ? `<br><span style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(z.zwingername)}</span>` : ''}</td>
|
||||
<td class="adm-td">${_esc([z.plz, z.ort, z.bundesland].filter(Boolean).join(' '))}</td>
|
||||
<td class="adm-td">${z.vdh_mitglied ? `<span style="color:var(--c-success);display:flex;align-items:center;gap:2px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> VDH</span>` : '—'}</td>
|
||||
<td class="adm-td">${_ageLabel(z.created_at)}</td>
|
||||
<td class="adm-td">${z.website ? `<a href="${_esc(z.website)}" target="_blank" style="color:var(--c-primary);font-size:var(--text-xs)">Link</a>` : '—'}</td>
|
||||
<td class="adm-td" style="text-align:right;white-space:nowrap">
|
||||
<button class="btn btn-sm btn-primary adm-zuchter-approve" data-id="${z.id}" style="margin-right:4px"><svg class="ph-icon" style="width:14px;height:14px" aria-hidden="true"><use href="/icons/phosphor.svg#check"></use></svg> Freigeben</button>
|
||||
|
|
@ -1590,10 +1450,6 @@ window.Page_admin = (() => {
|
|||
</tr>`).join('')}
|
||||
</tbody></table></div></div>`;
|
||||
}
|
||||
// Züchter-History
|
||||
if (zuchterDone.length) html += _historySection('Züchter-Einreichungen', zuchterDone,
|
||||
z => `<span style="font-weight:600">${_esc(z.name)}</span> · ${_esc(z.rasse_slug)} ·
|
||||
${UI.icon('check-circle')} ${_esc(z.verified_by_name||'?')} · ${(z.verified_at||'').slice(0,10)}`);
|
||||
|
||||
// --- Wiki-Foto-Einreichungen ---
|
||||
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
|
|
@ -1601,20 +1457,19 @@ window.Page_admin = (() => {
|
|||
margin-bottom:var(--space-3)">
|
||||
Wiki-Foto-Einreichungen
|
||||
<span style="background:var(--c-primary);color:#fff;border-radius:999px;
|
||||
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotosPending.length}</span>
|
||||
padding:1px 8px;font-size:var(--text-xs);margin-left:6px">${fotos.length}</span>
|
||||
</h3>`;
|
||||
|
||||
if (!fotosPending.length) {
|
||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine ausstehenden Foto-Einreichungen.</p>`;
|
||||
if (!fotos.length) {
|
||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine ausstehenden Foto-Einreichungen.</p>`;
|
||||
} else {
|
||||
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4);margin-bottom:var(--space-3)">
|
||||
${fotosPending.map(f => `
|
||||
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4)">
|
||||
${fotos.map(f => `
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
<img src="${_esc(f.foto_url)}" alt=""
|
||||
style="width:100%;height:140px;object-fit:cover;border-radius:var(--radius-md);margin-bottom:var(--space-3)">
|
||||
<div style="font-weight:var(--weight-semibold);font-size:var(--text-sm)">${_esc(f.rasse_name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">von ${_esc(f.user_name)}</div>
|
||||
<div style="margin-bottom:var(--space-3)">${_ageLabel(f.created_at)}</div>
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-3)">von ${_esc(f.user_name)}</div>
|
||||
${f.aktuell_foto ? `<img src="${_esc(f.aktuell_foto)}" alt="Aktuell"
|
||||
style="width:100%;height:80px;object-fit:cover;border-radius:var(--radius-sm);
|
||||
opacity:.5;margin-bottom:var(--space-2)">
|
||||
|
|
@ -1627,111 +1482,6 @@ window.Page_admin = (() => {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
// Fotos-History
|
||||
if (fotosDone.length) html += _historySection('Foto-Einreichungen', fotosDone,
|
||||
f => `<img src="${_esc(f.foto_url)}" style="width:32px;height:32px;object-fit:cover;border-radius:4px;vertical-align:middle;margin-right:6px">
|
||||
<span style="font-weight:600">${_esc(f.rasse_name||'?')}</span> · von ${_esc(f.user_name||'?')} ·
|
||||
${f.status==='approved' ? `${UI.icon('check-circle')} genehmigt` : `${UI.icon('x-circle')} abgelehnt`}
|
||||
${f.reviewed_by_name ? ` von ${_esc(f.reviewed_by_name)}` : ''} · ${(f.reviewed_at||'').slice(0,10)}`);
|
||||
|
||||
// --- Forum-Meldungen ---
|
||||
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
|
||||
margin:var(--space-4) 0 var(--space-3)">
|
||||
Forum-Meldungen
|
||||
<span style="background:${reportsPending.length ? 'var(--c-danger)' : 'var(--c-primary)'};color:#fff;
|
||||
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
|
||||
${reportsPending.length}
|
||||
</span>
|
||||
</h3>`;
|
||||
if (!reportsPending.length) {
|
||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-bottom:var(--space-3)">Keine offenen Meldungen.</p>`;
|
||||
} else {
|
||||
html += `<div style="display:flex;flex-direction:column;gap:var(--space-3);margin-bottom:var(--space-3)">
|
||||
${reportsPending.map(r => `
|
||||
<div class="card" style="padding:var(--space-4);border-left:3px solid var(--c-danger)">
|
||||
<div style="display:flex;align-items:flex-start;gap:var(--space-3)">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1);display:flex;align-items:center;flex-wrap:wrap;gap:4px">
|
||||
${_esc(r.target_type)} #${r.target_id} · Gemeldet von <strong>${_esc(r.melder_name || '?')}</strong>
|
||||
${_ageLabel(r.created_at)}
|
||||
</div>
|
||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);margin-bottom:var(--space-1)">
|
||||
Grund: ${_esc(r.grund)}
|
||||
</div>
|
||||
${r.content_preview ? `
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||
padding:var(--space-2) var(--space-3);background:var(--c-surface-2);
|
||||
border-radius:var(--radius-sm)">${_esc(r.content_preview)}</div>` : ''}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary adm-mod-resolve" data-rid="${r.id}" title="Als erledigt markieren">
|
||||
${UI.icon('check')}
|
||||
</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Meldungen-History
|
||||
if (reportsDone.length) html += _historySection('Forum-Meldungen', reportsDone,
|
||||
r => `${_esc(r.target_type)} #${r.target_id} · ${_esc(r.grund)} · Gemeldet von ${_esc(r.melder_name||'?')} ·
|
||||
${UI.icon('check-circle')} ${_esc(r.resolved_by_name||'?')} · ${(r.resolved_at||'').slice(0,10)}`);
|
||||
|
||||
// --- POI-Korrekturen ---
|
||||
html += `<h3 style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||
color:var(--c-text-muted);text-transform:uppercase;letter-spacing:.06em;
|
||||
margin:var(--space-2) 0 var(--space-3)">
|
||||
POI-Korrekturen
|
||||
<span style="background:${poiPending.length ? 'var(--c-warning,#e65100)' : 'var(--c-primary)'};color:#fff;
|
||||
border-radius:999px;padding:1px 8px;font-size:var(--text-xs);margin-left:6px">
|
||||
${poiPending.length}
|
||||
</span>
|
||||
</h3>`;
|
||||
if (!poiPending.length) {
|
||||
html += `<p style="font-size:var(--text-sm);color:var(--c-text-muted)">Keine ausstehenden POI-Korrekturen.</p>`;
|
||||
} else {
|
||||
html += `<div class="card adm-table-card"><div class="adm-table-scroll">
|
||||
<table class="adm-table">
|
||||
<thead><tr style="background:var(--c-surface-2);text-align:left">
|
||||
<th class="adm-th">Ort</th>
|
||||
<th class="adm-th">Feld</th>
|
||||
<th class="adm-th">Alt</th>
|
||||
<th class="adm-th">Neu</th>
|
||||
<th class="adm-th">Von</th>
|
||||
<th class="adm-th">Alter</th>
|
||||
<th class="adm-th"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${poiPending.map((e, i) => `
|
||||
<tr style="${i%2===1?'background:var(--c-surface-2)':''}">
|
||||
<td class="adm-td" style="font-weight:var(--weight-semibold)">${_esc(e.poi_name || `OSM #${e.osm_id}`)}</td>
|
||||
<td class="adm-td"><code style="font-size:var(--text-xs)">${_esc(e.field)}</code></td>
|
||||
<td class="adm-td" style="color:var(--c-text-muted);font-size:var(--text-xs)">${_esc(e.old_value || '—')}</td>
|
||||
<td class="adm-td" style="font-size:var(--text-xs)">${_esc(e.new_value || '—')}</td>
|
||||
<td class="adm-td" style="color:var(--c-text-muted)">${_esc(e.einreicher_name || '?')}</td>
|
||||
<td class="adm-td">${_ageLabel(e.created_at)}</td>
|
||||
<td class="adm-td" style="text-align:right;white-space:nowrap">
|
||||
<button class="btn btn-sm btn-primary adm-poi-approve" data-id="${e.id}" style="margin-right:4px">
|
||||
${UI.icon('check')}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost adm-poi-reject" data-id="${e.id}" style="color:var(--c-danger)">
|
||||
${UI.icon('x')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>`;
|
||||
}
|
||||
// POI-History
|
||||
if (poiDone.length) html += _historySection('POI-Korrekturen', poiDone,
|
||||
e => `<span style="font-weight:600">${_esc(e.poi_name||`OSM #${e.osm_id}`)}</span> ·
|
||||
<code style="font-size:var(--text-xs)">${_esc(e.field)}</code>:
|
||||
<span style="text-decoration:line-through;color:var(--c-text-muted)">${_esc(e.old_value||'—')}</span> →
|
||||
${_esc(e.new_value||'—')} ·
|
||||
${e.status==='approved' ? `${UI.icon('check-circle')} freigegeben` : `${UI.icon('x-circle')} abgelehnt`}
|
||||
${e.mod_name ? ` von ${_esc(e.mod_name)}` : ''} · ${(e.resolved_at||'').slice(0,10)}`);
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Züchter freigeben
|
||||
|
|
@ -1770,41 +1520,6 @@ window.Page_admin = (() => {
|
|||
await _loadModeration(el);
|
||||
});
|
||||
});
|
||||
|
||||
// Forum-Meldung erledigen
|
||||
el.querySelectorAll('.adm-mod-resolve').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await API.patch(`/moderation/reports/${btn.dataset.rid}`, {});
|
||||
await _loadModeration(el);
|
||||
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
|
||||
// POI-Korrektur freigeben
|
||||
el.querySelectorAll('.adm-poi-approve').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'approve' });
|
||||
UI.toast.success('Korrektur übernommen.');
|
||||
await _loadModeration(el);
|
||||
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
|
||||
// POI-Korrektur ablehnen
|
||||
el.querySelectorAll('.adm-poi-reject').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await API.patch(`/moderation/poi-edits/${btn.dataset.id}`, { action: 'reject' });
|
||||
UI.toast.success('Korrektur abgelehnt.');
|
||||
await _loadModeration(el);
|
||||
} catch (e) { UI.toast.error(e.message); btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
|
@ -2419,10 +2134,8 @@ window.Page_admin = (() => {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${log.map((l, i) => `
|
||||
<tr data-log-idx="${i}" style="border-bottom:1px solid var(--c-border);cursor:pointer"
|
||||
onmouseover="this.style.background='var(--c-surface-2)'"
|
||||
onmouseout="this.style.background=''">
|
||||
${log.map(l => `
|
||||
<tr style="border-bottom:1px solid var(--c-border)">
|
||||
<td style="padding:var(--space-2)">${accountBadge(l.from_account)}</td>
|
||||
<td style="padding:var(--space-2)">${_esc(l.recipient)}</td>
|
||||
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td>
|
||||
|
|
@ -2436,28 +2149,6 @@ window.Page_admin = (() => {
|
|||
</div>
|
||||
`;
|
||||
|
||||
// Log-Zeile: Mail-Inhalt anzeigen
|
||||
el.querySelectorAll('tr[data-log-idx]').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
const l = log[Number(row.dataset.logIdx)];
|
||||
if (!l) return;
|
||||
UI.modal.open({
|
||||
title: _esc(l.subject),
|
||||
body: `
|
||||
<div style="margin-bottom:var(--space-3);font-size:var(--text-sm);color:var(--c-text-muted)">
|
||||
<strong>An:</strong> ${_esc(l.recipient)} ·
|
||||
<strong>Von:</strong> ${_esc(l.from_account)}@banyaro.app ·
|
||||
${(l.sent_at||'').slice(0,16).replace('T',' ')}
|
||||
</div>
|
||||
<pre style="white-space:pre-wrap;font-family:inherit;font-size:var(--text-sm);
|
||||
background:var(--c-surface-2);border-radius:var(--radius-md);
|
||||
padding:var(--space-3);max-height:60vh;overflow-y:auto;
|
||||
color:var(--c-text)">${_esc(l.body || '(kein Text gespeichert)')}</pre>`,
|
||||
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Vorlage in Compose laden
|
||||
function _loadTplIntoCompose(id) {
|
||||
const tpl = templates.find(t => t.id === id);
|
||||
|
|
|
|||
|
|
@ -97,19 +97,8 @@ window.Page_dog_profile = (() => {
|
|||
<h2 style="font-size:var(--text-2xl);font-weight:700;
|
||||
color:var(--c-text);margin:0 0 var(--space-1)">${_esc(dog.name)}</h2>
|
||||
${dog.rasse
|
||||
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-2)">${_esc(dog.rasse)}</p>`
|
||||
: `<p style="margin:0 0 var(--space-2)"></p>`}
|
||||
|
||||
${(dog.hdm_wins?.length) ? `
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);justify-content:center;margin-bottom:var(--space-5)">
|
||||
${dog.hdm_wins.map(m => {
|
||||
const [y, mo] = m.split('-');
|
||||
const label = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
|
||||
.format(new Date(+y, +mo - 1, 1));
|
||||
return `<span class="dp-hdm-badge" title="Hund des Monats ${label}">🏆 ${label}</span>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
` : `<div style="margin-bottom:var(--space-5)"></div>`}
|
||||
? `<p style="color:var(--c-text-secondary);margin:0 0 var(--space-5)">${_esc(dog.rasse)}</p>`
|
||||
: `<p style="margin:0 0 var(--space-5)"></p>`}
|
||||
|
||||
<!-- Info-Grid -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3);
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ window.Page_forum = (() => {
|
|||
_container = container;
|
||||
_appState = appState;
|
||||
_render();
|
||||
_loadHdmCard();
|
||||
_loadThreads(true);
|
||||
}
|
||||
|
||||
|
|
@ -99,17 +98,15 @@ window.Page_forum = (() => {
|
|||
<div class="forum-category-tabs by-tabs" id="forum-tabs">
|
||||
${KATEGORIEN.map(k => `
|
||||
<button class="by-tab ${k.key === _aktivKat ? 'active' : ''}"
|
||||
data-kat="${k.key}"><span class="by-tab-text">${_esc(k.label)}</span></button>
|
||||
data-kat="${k.key}">${_esc(k.label)}</button>
|
||||
`).join('')}
|
||||
<button class="by-tab ${_activeSection === 'map' ? 'active' : ''}"
|
||||
data-section="map"><span class="by-tab-text">${UI.icon('users')} Mitgliederkarte</span></button>
|
||||
data-section="map">${UI.icon('users')} Mitgliederkarte</button>
|
||||
</div>
|
||||
|
||||
<!-- Rechte Spalte: HdM-Kachel + Suche + Threads -->
|
||||
<!-- Rechte Spalte: Suche + Threads -->
|
||||
<div class="forum-main-col">
|
||||
|
||||
<div id="forum-hdm-card"></div>
|
||||
|
||||
<div class="forum-search-wrap">
|
||||
<input type="search" class="forum-search" id="forum-search"
|
||||
placeholder="Forum durchsuchen…" autocomplete="off">
|
||||
|
|
@ -130,23 +127,6 @@ window.Page_forum = (() => {
|
|||
const _tabCount = _tabsEl.querySelectorAll('.by-tab').length;
|
||||
_tabsEl.style.setProperty('--forum-tab-cols', Math.ceil(_tabCount / 2));
|
||||
|
||||
// Marquee-Scroll: nur Tabs animieren, bei denen Text wirklich abgeschnitten ist
|
||||
_tabsEl.addEventListener('mouseenter', e => {
|
||||
const btn = e.target.closest('.by-tab');
|
||||
const span = btn?.querySelector('.by-tab-text');
|
||||
if (!span) return;
|
||||
const style = getComputedStyle(btn);
|
||||
const padH = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
||||
const overflow = span.scrollWidth - (btn.clientWidth - padH);
|
||||
if (overflow <= 2) return;
|
||||
span.style.setProperty('--tab-scroll-px', `-${overflow}px`);
|
||||
span.classList.add('scrolling');
|
||||
}, true);
|
||||
_tabsEl.addEventListener('mouseleave', e => {
|
||||
const span = e.target.closest('.by-tab')?.querySelector('.by-tab-text');
|
||||
if (span) span.classList.remove('scrolling');
|
||||
}, true);
|
||||
|
||||
// Tab-Klicks
|
||||
_tabsEl.addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-kat], [data-section]');
|
||||
|
|
@ -195,177 +175,6 @@ window.Page_forum = (() => {
|
|||
document.getElementById('forum-rules-btn').addEventListener('click', _showRules);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Hund des Monats — Kachel + Modal
|
||||
// ----------------------------------------------------------
|
||||
async function _loadHdmCard() {
|
||||
const card = document.getElementById('forum-hdm-card');
|
||||
if (!card) return;
|
||||
try {
|
||||
const data = await API.get('/movies/hund-des-monats');
|
||||
const [year, month] = data.monat.split('-');
|
||||
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long' })
|
||||
.format(new Date(+year, +month - 1, 1));
|
||||
const top = data.top?.[0];
|
||||
const winnerLine = top
|
||||
? `🥇 ${_esc(top.name)}${top.rasse ? ` · ${_esc(top.rasse)}` : ''}`
|
||||
: 'Noch keine Stimmen';
|
||||
const metaLine = top
|
||||
? `${top.stimmen} Stimme${top.stimmen !== 1 ? 'n' : ''}`
|
||||
: 'Sei der Erste!';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="forum-hdm-tile" id="forum-hdm-tile">
|
||||
<div class="forum-hdm-tile-trophy">🏆</div>
|
||||
<div class="forum-hdm-tile-body">
|
||||
<div class="forum-hdm-tile-title">Hund des Monats · ${_esc(monthName)}</div>
|
||||
<div class="forum-hdm-tile-winner">${winnerLine}</div>
|
||||
<div class="forum-hdm-tile-meta">${metaLine}</div>
|
||||
</div>
|
||||
<div class="forum-hdm-tile-cta">${UI.icon('arrow-right')}</div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById('forum-hdm-tile')?.addEventListener('click', () => _openHdmModal(data));
|
||||
} catch {
|
||||
// Kachel bleibt leer bei Fehler
|
||||
}
|
||||
}
|
||||
|
||||
async function _openHdmModal(data) {
|
||||
try { data = await API.get('/movies/hund-des-monats'); } catch { /* gecachte Daten */ }
|
||||
|
||||
const [year, month] = data.monat.split('-');
|
||||
const monthName = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
|
||||
.format(new Date(+year, +month - 1, 1));
|
||||
|
||||
const topList = data.top?.length
|
||||
? data.top.slice(0, 5).map((dog, i) => {
|
||||
const medal = ['🥇','🥈','🥉','4️⃣','5️⃣'][i];
|
||||
const av = dog.foto_url
|
||||
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-top-av-img">`
|
||||
: `<span class="hdm-top-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
|
||||
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
|
||||
return `
|
||||
<div class="hdm-top-entry">
|
||||
<span class="hdm-top-medal">${medal}</span>
|
||||
<div class="hdm-top-av">${av}</div>
|
||||
<div class="hdm-top-info">
|
||||
<div class="hdm-top-name">${_esc(dog.name)}</div>
|
||||
${dog.rasse ? `<div class="hdm-top-rasse">${_esc(dog.rasse)}</div>` : ''}
|
||||
${vorname ? `<div class="hdm-top-besitzer">von ${vorname}</div>` : ''}
|
||||
</div>
|
||||
<div class="hdm-top-stimmen">${dog.stimmen} ${UI.icon('star')}</div>
|
||||
</div>`;
|
||||
}).join('')
|
||||
: `<p style="color:var(--c-text-secondary);padding:var(--space-4)">Noch keine Stimmen. Sei der Erste!</p>`;
|
||||
|
||||
const voteHint = !_appState.user
|
||||
? `<div class="hdm-section">
|
||||
<p style="color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||||
<a href="#" id="hdm-login-link" style="color:var(--c-primary);font-weight:var(--weight-semibold)">Anmelden</a>
|
||||
um abstimmen zu können.
|
||||
</p>
|
||||
</div>`
|
||||
: `<div class="hdm-section">
|
||||
<h3 class="hdm-section-title">Für welchen Hund möchtest du abstimmen?</h3>
|
||||
<div class="hdm-kandidaten-search">
|
||||
<input type="search" id="hdm-search" class="form-control"
|
||||
placeholder="Name oder Rasse suchen …" autocomplete="off"
|
||||
style="font-size:var(--text-sm)">
|
||||
</div>
|
||||
<div id="hdm-kandidaten-grid" class="hdm-vote-grid">
|
||||
${UI.skeleton(3)}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const body = `
|
||||
<div class="hdm-header">
|
||||
<div class="hdm-trophy">🏆</div>
|
||||
<h2 class="hdm-title">Hund des Monats</h2>
|
||||
<div class="hdm-monat">${_esc(monthName)}</div>
|
||||
</div>
|
||||
${voteHint}
|
||||
<div class="hdm-section">
|
||||
<h3 class="hdm-section-title">Top 5 diesen Monat</h3>
|
||||
<div id="hdm-top-list">${topList}</div>
|
||||
</div>`;
|
||||
|
||||
UI.modal.open({ title: '🏆 Hund des Monats', body,
|
||||
footer: `<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Schließen</button>` });
|
||||
|
||||
document.getElementById('hdm-login-link')?.addEventListener('click', e => {
|
||||
e.preventDefault(); UI.modal.close(); App.navigate('settings');
|
||||
});
|
||||
|
||||
if (!_appState.user) return;
|
||||
|
||||
// Kandidaten laden und rendern
|
||||
let _kandidaten = [];
|
||||
const _renderKandidaten = (list) => {
|
||||
const grid = document.getElementById('hdm-kandidaten-grid');
|
||||
if (!grid) return;
|
||||
if (!list.length) {
|
||||
grid.innerHTML = `<p style="color:var(--c-text-secondary);font-size:var(--text-sm);padding:var(--space-3) 0">Keine Hunde gefunden.</p>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = list.map(dog => {
|
||||
const isVoted = data.user_vote === dog.id;
|
||||
const av = dog.foto_url
|
||||
? `<img src="${_esc(dog.foto_url)}" alt="${_esc(dog.name)}" class="hdm-vote-av-img">`
|
||||
: `<span class="hdm-vote-av-placeholder">${_esc(dog.name.charAt(0).toUpperCase())}</span>`;
|
||||
const vorname = dog.besitzer_name ? _esc(dog.besitzer_name.split(' ')[0]) : '';
|
||||
return `
|
||||
<div class="hdm-vote-card${isVoted ? ' hdm-vote-card--voted' : ''}">
|
||||
<div class="hdm-vote-av">${av}</div>
|
||||
<div class="hdm-vote-name">${_esc(dog.name)}</div>
|
||||
${dog.rasse ? `<div class="hdm-vote-rasse">${_esc(dog.rasse)}</div>` : ''}
|
||||
${vorname ? `<div class="hdm-vote-besitzer" style="font-size:var(--text-xs);color:var(--c-text-muted)">von ${vorname}</div>` : ''}
|
||||
${dog.stimmen > 0 ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted)">${dog.stimmen} ${UI.icon('star')}</div>` : ''}
|
||||
<button class="btn btn-sm ${isVoted ? 'btn-primary' : 'btn-secondary'} hdm-vote-btn"
|
||||
data-dog-id="${dog.id}" ${isVoted ? 'disabled' : ''}>
|
||||
${isVoted ? `${UI.icon('check-circle')} Gewählt` : 'Abstimmen'}
|
||||
</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
grid.querySelectorAll('.hdm-vote-btn:not([disabled])').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const dogId = parseInt(btn.dataset.dogId);
|
||||
await UI.asyncButton(btn, async () => {
|
||||
try {
|
||||
await API.post('/movies/hund-des-monats/vote', { dog_id: dogId });
|
||||
data.user_vote = dogId;
|
||||
UI.toast.success('Stimme abgegeben!');
|
||||
UI.modal.close();
|
||||
_loadHdmCard();
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Abstimmen.');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
_kandidaten = await API.get('/movies/hund-des-monats/kandidaten');
|
||||
} catch {
|
||||
document.getElementById('hdm-kandidaten-grid').innerHTML =
|
||||
`<p style="color:var(--c-danger);font-size:var(--text-sm)">Kandidaten konnten nicht geladen werden.</p>`;
|
||||
return;
|
||||
}
|
||||
_renderKandidaten(_kandidaten);
|
||||
|
||||
document.getElementById('hdm-search')?.addEventListener('input', e => {
|
||||
const q = e.target.value.trim().toLowerCase();
|
||||
_renderKandidaten(q
|
||||
? _kandidaten.filter(d =>
|
||||
(d.name || '').toLowerCase().includes(q) ||
|
||||
(d.rasse || '').toLowerCase().includes(q))
|
||||
: _kandidaten
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Threads laden
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ window.Page_movies = (() => {
|
|||
let _filter = 'alle';
|
||||
let _typ = 'alle'; // alle | film | serie | doku
|
||||
let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung
|
||||
let _search = '';
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
|
|
@ -42,6 +41,7 @@ window.Page_movies = (() => {
|
|||
<div class="movies-tabs">
|
||||
<button class="movies-tab${_activeTab === 'filme' ? ' movies-tab--active' : ''}" data-tab="filme">${UI.icon('film-slate')} Filme</button>
|
||||
<button class="movies-tab${_activeTab === 'promis' ? ' movies-tab--active' : ''}" data-tab="promis">${UI.icon('star')} Berühmtheiten</button>
|
||||
<button class="movies-tab${_activeTab === 'hdm' ? ' movies-tab--active' : ''}" data-tab="hdm">${UI.icon('paw-print')} Hund des Monats</button>
|
||||
</div>
|
||||
<div id="movies-tab-content"></div>
|
||||
`;
|
||||
|
|
@ -66,6 +66,7 @@ window.Page_movies = (() => {
|
|||
|
||||
if (_activeTab === 'filme') await _renderFilme(content);
|
||||
if (_activeTab === 'promis') _renderPromis(content);
|
||||
if (_activeTab === 'hdm') await _renderHundDesMonats(content);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -85,11 +86,6 @@ window.Page_movies = (() => {
|
|||
|
||||
content.innerHTML = `
|
||||
<div class="movies-controls">
|
||||
<div class="movies-search-row">
|
||||
<svg class="ph-icon movies-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
|
||||
<input type="search" id="movies-search" class="form-control movies-search-input"
|
||||
placeholder="Film, Serie oder Rasse suchen …" value="${_esc(_search)}" autocomplete="off">
|
||||
</div>
|
||||
<div class="movies-filter-row">
|
||||
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
|
||||
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</button>
|
||||
|
|
@ -139,11 +135,6 @@ window.Page_movies = (() => {
|
|||
});
|
||||
});
|
||||
|
||||
content.querySelector('#movies-search')?.addEventListener('input', e => {
|
||||
_search = e.target.value.trim().toLowerCase();
|
||||
_renderMovieGrid(content.querySelector('#movie-grid'));
|
||||
});
|
||||
|
||||
content.querySelector('#movies-sort')?.addEventListener('change', async e => {
|
||||
_sort = e.target.value;
|
||||
const grid = content.querySelector('#movie-grid');
|
||||
|
|
@ -162,14 +153,6 @@ window.Page_movies = (() => {
|
|||
if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund);
|
||||
if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund);
|
||||
if (_filter === 'top') list = list.filter(f => (f.imdb_rating || 0) >= 7.5 || f.bewertung_avg >= 4.0);
|
||||
if (_search) {
|
||||
list = list.filter(f =>
|
||||
(f.titel || '').toLowerCase().includes(_search) ||
|
||||
(f.hund_rasse || '').toLowerCase().includes(_search) ||
|
||||
(f.genre || '').toLowerCase().includes(_search) ||
|
||||
(f.beschreibung || '').toLowerCase().includes(_search)
|
||||
);
|
||||
}
|
||||
|
||||
const countEl = document.getElementById('movies-count');
|
||||
if (countEl) countEl.textContent = `${list.length} Einträge`;
|
||||
|
|
|
|||
|
|
@ -1,320 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Presse – Ban Yaro</title>
|
||||
<meta name="description" content="Pressematerial, Logos und Screenshots für Redaktionen – Ban Yaro Hunde-App">
|
||||
<meta name="robots" content="noindex">
|
||||
<link rel="icon" href="/icons/favicon.ico">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--primary: #b97c2a;
|
||||
--primary-light: #f5ede0;
|
||||
--text: #1a1a1a;
|
||||
--muted: #666;
|
||||
--border: #e5e0d8;
|
||||
--bg: #faf9f7;
|
||||
--white: #fff;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
background: var(--white);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 1.25rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
header img { width: 40px; height: 40px; border-radius: 10px; }
|
||||
header .brand { font-size: 1.25rem; font-weight: 700; color: var(--text); }
|
||||
header .brand span { color: var(--primary); }
|
||||
header a { margin-left: auto; color: var(--primary); font-size: .9rem; text-decoration: none; }
|
||||
|
||||
/* Layout */
|
||||
.container { max-width: 860px; margin: 0 auto; padding: 3rem 1.5rem; }
|
||||
|
||||
h1 { font-size: 2rem; font-weight: 800; margin-bottom: .5rem; }
|
||||
h2 { font-size: 1.25rem; font-weight: 700; margin-bottom: 1.25rem; color: var(--text); }
|
||||
.lead { color: var(--muted); margin-bottom: 3rem; font-size: 1.05rem; }
|
||||
|
||||
/* Sections */
|
||||
section { margin-bottom: 3.5rem; }
|
||||
.section-label {
|
||||
font-size: .75rem; font-weight: 700; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--primary);
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
/* Press release */
|
||||
.pressemitteilung {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2rem 2.5rem;
|
||||
}
|
||||
.pressemitteilung .pm-meta {
|
||||
font-size: .85rem; color: var(--muted); margin-bottom: 1.5rem;
|
||||
}
|
||||
.pressemitteilung h3 {
|
||||
font-size: 1.4rem; font-weight: 800; margin-bottom: .35rem; line-height: 1.3;
|
||||
}
|
||||
.pressemitteilung .pm-sub {
|
||||
font-size: .95rem; color: var(--muted); font-style: italic; margin-bottom: 1.5rem;
|
||||
}
|
||||
.pressemitteilung p { margin-bottom: 1rem; color: #333; }
|
||||
.pressemitteilung blockquote {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
color: #444;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Downloads */
|
||||
.download-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.download-card {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
.download-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); }
|
||||
.download-card .thumb {
|
||||
width: 100%; aspect-ratio: 9/16; object-fit: cover;
|
||||
background: var(--primary-light);
|
||||
display: block;
|
||||
}
|
||||
.download-card.logo-card .thumb {
|
||||
aspect-ratio: 1; object-fit: contain; padding: 1.5rem;
|
||||
}
|
||||
.download-card .card-label {
|
||||
padding: .6rem .85rem;
|
||||
font-size: .8rem; color: var(--muted);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.download-card .card-label span { font-weight: 600; color: var(--text); font-size: .85rem; }
|
||||
.dl-icon { color: var(--primary); font-size: 1rem; }
|
||||
|
||||
/* Founder */
|
||||
.founder-card {
|
||||
display: flex; gap: 2rem; align-items: flex-start;
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2rem;
|
||||
}
|
||||
.founder-card img {
|
||||
width: 140px; height: 140px;
|
||||
border-radius: 50%; object-fit: cover; flex-shrink: 0;
|
||||
border: 3px solid var(--primary-light);
|
||||
}
|
||||
.founder-card h3 { font-size: 1.1rem; font-weight: 700; margin-bottom: .25rem; }
|
||||
.founder-card .role { color: var(--primary); font-size: .9rem; margin-bottom: .75rem; }
|
||||
.founder-card p { color: #444; font-size: .95rem; }
|
||||
|
||||
/* Facts */
|
||||
.facts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.fact {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.fact .fact-label { font-size: .8rem; color: var(--muted); margin-bottom: .25rem; }
|
||||
.fact .fact-value { font-size: 1rem; font-weight: 700; }
|
||||
|
||||
/* Contact */
|
||||
.contact-box {
|
||||
background: var(--primary-light);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.75rem 2rem;
|
||||
}
|
||||
.contact-box p { margin-bottom: .35rem; }
|
||||
.contact-box a { color: var(--primary); font-weight: 600; text-decoration: none; }
|
||||
|
||||
/* Boilerplate */
|
||||
.boilerplate {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem 2rem;
|
||||
font-size: .9rem;
|
||||
color: #444;
|
||||
position: relative;
|
||||
}
|
||||
.copy-btn {
|
||||
position: absolute; top: 1rem; right: 1rem;
|
||||
background: var(--primary); color: white;
|
||||
border: none; border-radius: 6px;
|
||||
padding: .35rem .75rem; font-size: .75rem;
|
||||
cursor: pointer; font-weight: 600;
|
||||
}
|
||||
.copy-btn:hover { opacity: .85; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.founder-card { flex-direction: column; }
|
||||
.founder-card img { width: 100px; height: 100px; }
|
||||
.pressemitteilung { padding: 1.5rem; }
|
||||
h1 { font-size: 1.5rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<img src="/icons/icon-180.png" alt="Ban Yaro Icon">
|
||||
<div class="brand">Ban <span>Yaro</span></div>
|
||||
<a href="https://banyaro.app">← Zur App</a>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<h1>Pressematerial</h1>
|
||||
<p class="lead">Logos, Screenshots und Hintergrundinformationen für Redaktionen. Alle Materialien sind zur redaktionellen Verwendung freigegeben.</p>
|
||||
|
||||
<!-- Pressemitteilung -->
|
||||
<section>
|
||||
<div class="section-label">Pressemitteilung</div>
|
||||
<div class="pressemitteilung">
|
||||
<div class="pm-meta">Ebersberg, 1. Mai 2026 — zur sofortigen Veröffentlichung freigegeben</div>
|
||||
<h3>Vom Gipfelfoto bis zum Giftköder-Alarm: App begleitet Hundehalter durch den ganzen Alltag</h3>
|
||||
<p class="pm-sub">banyaro.app bündelt Tagebuch, Gesundheitsakte und Echtzeit-Warnungen in einer kostenlosen Hunde-App</p>
|
||||
|
||||
<p>Manche Gassi-Runden sind einfach unvergesslich — der erste Schnee, der perfekte Sonnenuntergang, die Stelle am Bach, an der der Hund immer ins Wasser springt. Andere hinterlassen Angst: Ein verdächtiges Häufchen am Wegesrand, ein Hund der plötzlich würgt.</p>
|
||||
|
||||
<p>Für beides gibt es jetzt eine App: <strong>banyaro.app</strong> ist eine kostenlose Hunde-App aus Bayern, die den ganzen Alltag mit Hund begleitet — von den schönsten Momenten bis zu den gefährlichen.</p>
|
||||
|
||||
<p>Im <strong>Hunde-Tagebuch</strong> lassen sich Fotos, Notizen und Erinnerungen sammeln, in der <strong>Gesundheitsakte</strong> Impftermine, Medikamente und Tierarztbesuche verwalten. Die interaktive <strong>Karte</strong> zeigt die besten Hundewiesen, Wasserstellen und Auslaufgebiete in der Umgebung — und die schönsten Routen für die nächste Gassi-Runde.</p>
|
||||
|
||||
<p>Der <strong>Giftköder-Alarm</strong> funktioniert nach dem Prinzip der Schwarmintelligenz: Wer einen verdächtigen Fund meldet, macht ihn sofort auf der Karte für alle anderen Hundehalter in der Region sichtbar. Keine Facebook-Gruppe, kein verschwundener Post — die Warnung bleibt dauerhaft abrufbar.</p>
|
||||
|
||||
<blockquote>„Ich wollte eine App bauen, die sich wie ein stiller Begleiter anfühlt — die im Hintergrund läuft, Erinnerungen sammelt und im Ernstfall sofort warnt. Kein App Store, keine Kosten, keine Werbung."<br><strong>— René Degelmann, Gründer</strong></blockquote>
|
||||
|
||||
<p>banyaro.app ist direkt unter <strong>banyaro.app</strong> erreichbar — ohne Installation, direkt im Smartphone-Browser.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Kurzprofil / Boilerplate -->
|
||||
<section>
|
||||
<div class="section-label">Über Ban Yaro — Kurztext für Redaktionen</div>
|
||||
<div class="boilerplate" id="boilerplate-text">
|
||||
<button class="copy-btn" onclick="copyBoilerplate()">Kopieren</button>
|
||||
<p>Ban Yaro ist eine kostenlose Hunde-App für den deutschsprachigen Raum. Die App läuft als Progressive Web App direkt im Smartphone-Browser — ohne Installation über den App Store. Funktionen: Hunde-Tagebuch mit Fotos und Wetter, digitale Gesundheitsakte, interaktive Karte mit Hundewiesen und Giftköder-Alarm, Community-Forum und Trainingspläne. Gegründet 2024 von René Degelmann, Ebersberg bei München. Erreichbar unter banyaro.app.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Screenshots -->
|
||||
<section>
|
||||
<div class="section-label">Screenshots — zur redaktionellen Verwendung freigegeben</div>
|
||||
<div class="download-grid">
|
||||
<a class="download-card" href="/img/screenshots/screen-1.jpg" download="banyaro-tagebuch.jpg">
|
||||
<img class="thumb" src="/img/screenshots/screen-1.jpg" alt="Tagebuch">
|
||||
<div class="card-label"><span>Tagebuch</span> <span class="dl-icon">↓</span></div>
|
||||
</a>
|
||||
<a class="download-card" href="/img/screenshots/screen-2.jpg" download="banyaro-karte-giftkoederr.jpg">
|
||||
<img class="thumb" src="/img/screenshots/screen-2.jpg" alt="Karte & Giftköder-Alarm">
|
||||
<div class="card-label"><span>Karte & Alarm</span> <span class="dl-icon">↓</span></div>
|
||||
</a>
|
||||
<a class="download-card" href="/img/screenshots/screen-3.jpg" download="banyaro-gesundheitsakte.jpg">
|
||||
<img class="thumb" src="/img/screenshots/screen-3.jpg" alt="Gesundheitsakte">
|
||||
<div class="card-label"><span>Gesundheitsakte</span> <span class="dl-icon">↓</span></div>
|
||||
</a>
|
||||
<a class="download-card" href="/img/screenshots/screen-9.jpg" download="banyaro-forum.jpg">
|
||||
<img class="thumb" src="/img/screenshots/screen-9.jpg" alt="Forum & Community">
|
||||
<div class="card-label"><span>Forum</span> <span class="dl-icon">↓</span></div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Logo -->
|
||||
<section>
|
||||
<div class="section-label">Logo</div>
|
||||
<div class="download-grid">
|
||||
<a class="download-card logo-card" href="/icons/icon-512.png" download="banyaro-logo-512.png">
|
||||
<img class="thumb" src="/icons/icon-512.png" alt="Ban Yaro Logo">
|
||||
<div class="card-label"><span>Logo PNG 512px</span> <span class="dl-icon">↓</span></div>
|
||||
</a>
|
||||
<a class="download-card logo-card" href="/icons/icon-192.png" download="banyaro-logo-192.png">
|
||||
<img class="thumb" src="/icons/icon-192.png" alt="Ban Yaro Logo 192">
|
||||
<div class="card-label"><span>Logo PNG 192px</span> <span class="dl-icon">↓</span></div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Gründer -->
|
||||
<section>
|
||||
<div class="section-label">Gründer</div>
|
||||
<div class="founder-card">
|
||||
<img src="/icons/founder.jpg" alt="René Degelmann mit Ban Yaro">
|
||||
<div>
|
||||
<h3>René Degelmann</h3>
|
||||
<div class="role">Gründer & Entwickler, Ban Yaro</div>
|
||||
<p>René Degelmann ist Softwareentwickler aus Ebersberg bei München. Ban Yaro hat er für seinen eigenen Hund gebaut — und dann gemerkt, dass tausende andere Hundehalter das gleiche brauchen. Die App entstand ohne Investoren, ohne App-Store-Zwang und ohne Werbung.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top:.75rem; font-size:.8rem; color:var(--muted);">Foto zur redaktionellen Verwendung freigegeben — <a href="/icons/founder.jpg" download="rene-degelmann-ban-yaro.jpg" style="color:var(--primary)">herunterladen ↓</a></p>
|
||||
</section>
|
||||
|
||||
<!-- Eckdaten -->
|
||||
<section>
|
||||
<div class="section-label">Eckdaten</div>
|
||||
<div class="facts-grid">
|
||||
<div class="fact"><div class="fact-label">Gegründet</div><div class="fact-value">2026</div></div>
|
||||
<div class="fact"><div class="fact-label">Sitz</div><div class="fact-value">Ebersberg bei München</div></div>
|
||||
<div class="fact"><div class="fact-label">Plattform</div><div class="fact-value">Progressive Web App</div></div>
|
||||
<div class="fact"><div class="fact-label">Preis</div><div class="fact-value">Kostenlos</div></div>
|
||||
<div class="fact"><div class="fact-label">Sprache</div><div class="fact-value">Deutsch</div></div>
|
||||
<div class="fact"><div class="fact-label">Zielmarkt</div><div class="fact-value">D-A-CH</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pressekontakt -->
|
||||
<section>
|
||||
<div class="section-label">Pressekontakt</div>
|
||||
<div class="contact-box">
|
||||
<p><strong>René Degelmann</strong></p>
|
||||
<p>Ringstr. 26 · 85560 Ebersberg</p>
|
||||
<p>Telefon: <a href="tel:+4917112096622">0171 1209622</a></p>
|
||||
<p>E-Mail: <a href="mailto:partner@banyaro.app">partner@banyaro.app</a></p>
|
||||
<p>Web: <a href="https://banyaro.app">banyaro.app</a></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyBoilerplate() {
|
||||
const text = document.getElementById('boilerplate-text').innerText.replace('Kopieren', '').trim();
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = document.querySelector('.copy-btn');
|
||||
btn.textContent = 'Kopiert ✓';
|
||||
setTimeout(() => btn.textContent = 'Kopieren', 2000);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v597';
|
||||
const CACHE_VERSION = 'by-v583';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue