Compare commits

...

2 commits

Author SHA1 Message Date
f378edab5d Jobs: Bewerbungssystem für Social-Media-Manager/in
Backend:
- job_applications + job_application_docs Tabellen in DB
- luna_trial_until Spalte in users (Migration)
- routes/jobs.py: POST /apply (FormData + Datei-Upload, max 3×10MB),
  GET /my-application, GET /luna-trial-status
- Admin: GET/PATCH /admin/applications, GET /admin/applications/{id}/docs/{doc_id}
- Bei Bewerbung: 14-Tage Luna-Probezugang automatisch aktiviert
- Bei Annahme: is_social_media=1 + Gründer-Status gesetzt
- Status-Mails (pending/reviewing/accepted/rejected) via email_html-Template
- auth.py: require_social_media prüft auch luna_trial_until

Frontend:
- pages/jobs.js: Stellenausschreibung + Bewerbungsformular
  (Name, E-Mail, Hund, Social-Handle, Motivation, Datei-Upload)
- Luna-Probezugang Teaser mit Countdown wenn aktiv
- Bestehende Bewerbung: Status-Screen statt Formular
- app.js: 'jobs' Seite registriert
- admin.js: neuer Tab 'Bewerbungen' (filtert nach Status,
  Statuswechsel per Dropdown, Detailansicht mit Anhang-Download,
  Admin-Notiz-Feld)
- admin.js: Tab 'Jobs' → 'Scheduler' umbenannt
2026-05-01 09:30:05 +02:00
59856e61a1 Filme: DB-Migration, 68 Einträge, Sort + Typ-Filter
- movies-Tabelle in SQLite (statt hardcoded Liste)
- seed_movies(): 68 Filme/Serien/Dokus beim ersten Start
- Felder: titel, originaltitel, jahr, genre, typ, hund_rasse,
  stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, streaming
- GET /api/movies/filme?sort=&typ= — serverseitig sortiert
  Sort: default | titel | jahr_desc | jahr_asc | imdb | bewertung
  Typ: alle | film | serie | doku
- Admin-CRUD: POST/PATCH/DELETE /api/movies/filme
- Frontend: Sort-Dropdown, Typ-Filter-Buttons (Filme/Serien/Dokus),
  Zähler, IMDb-Rating + Streaming auf der Karte
- Promis ebenfalls erweitert (10 statt 6 Einträge)
2026-05-01 08:50:01 +02:00
9 changed files with 1088 additions and 117 deletions

View file

@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"]) user_id = int(payload["sub"])
with db() as conn: with db() as conn:
row = conn.execute( row = conn.execute(
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified FROM users WHERE id=?", "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?",
(user_id,) (user_id,)
).fetchone() ).fetchone()
@ -131,7 +131,10 @@ def require_admin(user=Depends(get_current_user)):
def require_social_media(user=Depends(get_current_user)): def require_social_media(user=Depends(get_current_user)):
"""Dependency: Social-Media-Manager oder Admin.""" """Dependency: Social-Media-Manager, Luna-Probezugang oder Admin."""
if not (user.get("is_social_media") or user["rolle"] == "admin"): from datetime import datetime as _dt
trial = user.get("luna_trial_until")
trial_active = bool(trial and _dt.utcnow().isoformat() < trial)
if not (user.get("is_social_media") or user["rolle"] == "admin" or trial_active):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.") raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.")
return user return user

View file

@ -701,7 +701,28 @@ def _migrate(conn_factory):
CREATE INDEX IF NOT EXISTS idx_wiki_berichte_rasse ON wiki_berichte(rasse, created_at DESC); CREATE INDEX IF NOT EXISTS idx_wiki_berichte_rasse ON wiki_berichte(rasse, created_at DESC);
""") """)
# Hunde-Filme: Bewertungen + Hund des Monats # Hunde-Filme: Katalog + Bewertungen + Hund des Monats
conn.executescript("""
CREATE TABLE IF NOT EXISTS movies (
id TEXT PRIMARY KEY,
titel TEXT NOT NULL,
originaltitel TEXT,
jahr INTEGER,
genre TEXT,
typ TEXT NOT NULL DEFAULT 'film',
hund_rasse TEXT,
stirbt_der_hund INTEGER NOT NULL DEFAULT 0,
beschreibung TEXT,
bild_emoji TEXT DEFAULT '🐾',
imdb_rating REAL,
streaming TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_movies_typ ON movies(typ);
CREATE INDEX IF NOT EXISTS idx_movies_jahr ON movies(jahr DESC);
""")
conn.executescript(""" conn.executescript("""
CREATE TABLE IF NOT EXISTS movie_votes ( CREATE TABLE IF NOT EXISTS movie_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -1561,6 +1582,35 @@ def _migrate(conn_factory):
if 'from_account' not in existing_ol: if 'from_account' not in existing_ol:
conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'") conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'")
# Job-Bewerbungen + Luna-Probezugang
conn.executescript("""
CREATE TABLE IF NOT EXISTS job_applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
dog_name TEXT,
dog_rasse TEXT,
social_handle TEXT,
motivation TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
admin_note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
reviewed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_job_apps_status ON job_applications(status, created_at DESC);
CREATE TABLE IF NOT EXISTS job_application_docs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
application_id INTEGER NOT NULL REFERENCES job_applications(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
file_path TEXT NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
""")
existing_u = [row[1] for row in conn.execute("PRAGMA table_info(users)").fetchall()]
if 'luna_trial_until' not in existing_u:
conn.execute("ALTER TABLE users ADD COLUMN luna_trial_until TEXT")
# js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress # js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress
existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()] existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()]
if 'js_exercise_id' not in existing_te: if 'js_exercise_id' not in existing_te:

View file

@ -46,6 +46,8 @@ logger = logging.getLogger(__name__)
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.info("Ban Yaro startet...") logger.info("Ban Yaro startet...")
init_db() init_db()
from routes.movies import seed_movies
seed_movies()
logger.info(f"KI-Modus: {ki.KI_MODE}") logger.info(f"KI-Modus: {ki.KI_MODE}")
sched.start() sched.start()
yield yield
@ -186,6 +188,7 @@ from routes.breeder_export import router as breeder_export_router
from routes.zucht_ki import router as zucht_ki_router from routes.zucht_ki import router as zucht_ki_router
from routes.partner import router as partner_router from routes.partner import router as partner_router
from routes.outreach import router as outreach_router from routes.outreach import router as outreach_router
from routes.jobs import router as jobs_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -219,6 +222,7 @@ app.include_router(breeder_export_router, prefix="/api", tags=["Export"])
app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"]) app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"])
app.include_router(partner_router, prefix="/api", tags=["Partner"]) app.include_router(partner_router, prefix="/api", tags=["Partner"])
app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"]) app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"])
app.include_router(jobs_router, prefix="/api/jobs", tags=["Jobs"])
app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"])
app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"])
app.include_router(import_router, prefix="/api/import", tags=["Import"]) app.include_router(import_router, prefix="/api/import", tags=["Import"])

318
backend/routes/jobs.py Normal file
View file

@ -0,0 +1,318 @@
"""BAN YARO — Social-Media-Job Bewerbungs-System"""
import os
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
from typing import Optional
from database import db
from auth import get_current_user, get_current_user_optional, require_admin
from mailer import send_email, email_html
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
JOBS_DIR = os.path.join(MEDIA_DIR, "jobs")
TRIAL_DAYS = 14
MAX_FILES = 3
MAX_FILE_MB = 10
os.makedirs(JOBS_DIR, exist_ok=True)
_ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".mp4", ".mov"}
# ------------------------------------------------------------------
# POST /api/jobs/apply
# ------------------------------------------------------------------
async def apply(
name: str = Form(...),
email: str = Form(...),
dog_name: str = Form(""),
dog_rasse: str = Form(""),
social_handle: str = Form(...),
motivation: str = Form(...),
files: list[UploadFile] = File(default=[]),
user = Depends(get_current_user_optional),
):
if len(motivation.strip()) < 80:
raise HTTPException(400, "Bitte schreibe etwas mehr über dich (mindestens 80 Zeichen).")
if len(files) > MAX_FILES:
raise HTTPException(400, f"Maximal {MAX_FILES} Dateien erlaubt.")
user_id = user["id"] if user else None
# Doppelbewerbung verhindern
if user_id:
with db() as conn:
existing = conn.execute(
"SELECT id FROM job_applications WHERE user_id=? AND status NOT IN ('rejected')",
(user_id,)
).fetchone()
if existing:
raise HTTPException(400, "Du hast bereits eine aktive Bewerbung eingereicht.")
with db() as conn:
cur = conn.execute("""
INSERT INTO job_applications
(user_id, name, email, dog_name, dog_rasse, social_handle, motivation)
VALUES (?,?,?,?,?,?,?)
""", (user_id, name.strip(), email.strip(), dog_name.strip(),
dog_rasse.strip(), social_handle.strip(), motivation.strip()))
app_id = cur.lastrowid
# Dokumente speichern
app_dir = os.path.join(JOBS_DIR, str(app_id))
os.makedirs(app_dir, exist_ok=True)
for f in files:
if not f.filename:
continue
ext = os.path.splitext(f.filename)[1].lower()
if ext not in _ALLOWED_EXT:
continue
size = 0
safe_name = f"{uuid.uuid4().hex}{ext}"
dest = os.path.join(app_dir, safe_name)
with open(dest, "wb") as out:
while chunk := await f.read(65536):
size += len(chunk)
if size > MAX_FILE_MB * 1024 * 1024:
out.close()
os.remove(dest)
raise HTTPException(400, f"Datei zu groß (max. {MAX_FILE_MB} MB).")
out.write(chunk)
conn.execute("""
INSERT INTO job_application_docs (application_id, filename, file_path)
VALUES (?,?,?)
""", (app_id, f.filename, dest))
# Luna-Probezugang: 14 Tage ab sofort
if user_id:
trial_until = (datetime.utcnow() + timedelta(days=TRIAL_DAYS)).isoformat()
conn.execute(
"UPDATE users SET luna_trial_until=? WHERE id=?",
(trial_until, user_id)
)
# Bestätigungs-Mail an Bewerber
try:
body = f"""
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
<p style="margin:0 0 16px">
deine Bewerbung als Social-Media-Manager/in bei Ban Yaro ist bei uns eingegangen.
Wir melden uns bald bei dir!
</p>
{"<p style='margin:0 0 16px;background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;border-radius:0 8px 8px 0'><b>🎉 Luna-Probezugang aktiviert!</b><br>Du hast für 14 Tage kostenlos Zugang zu Luna, unserem KI-Social-Media-Assistenten. Logge dich ein und probiere ihn aus.</p>" if user_id else ""}
<p style="margin:0;color:#666;font-size:14px">Das Ban Yaro Team</p>"""
await send_email(
email,
"Deine Bewerbung bei Ban Yaro 🐾",
email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"),
f"Hallo {name}, deine Bewerbung ist eingegangen!",
)
except Exception:
pass
# Admin benachrichtigen
try:
admin_email = os.getenv("ADMIN_EMAIL", "")
if admin_email:
admin_body = f"""
<p style="margin:0 0 12px"><b>Neue Job-Bewerbung eingegangen:</b></p>
<table style="font-size:14px;border-collapse:collapse;width:100%">
<tr><td style="padding:4px 12px 4px 0;color:#888">Name</td><td><b>{name}</b></td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">E-Mail</td><td>{email}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Hund</td><td>{dog_name} ({dog_rasse})</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Social</td><td>{social_handle}</td></tr>
<tr><td style="padding:4px 12px 4px 0;color:#888">Anhänge</td><td>{len([f for f in files if f.filename])} Datei(en)</td></tr>
</table>
<p style="margin:12px 0 0;font-size:14px;color:#444">{motivation[:300]}{"" if len(motivation)>300 else ""}</p>"""
await send_email(
admin_email,
f"[Banyaro Jobs] Neue Bewerbung — {name}",
email_html(admin_body, cta_url="https://banyaro.app/#admin", cta_label="Im Admin-Bereich prüfen"),
f"Neue Bewerbung von {name} ({email})",
)
except Exception:
pass
return {
"ok": True,
"application_id": app_id,
"luna_trial": user_id is not None,
"trial_days": TRIAL_DAYS,
}
# FastAPI braucht expliziten Router-Decorator
router.add_api_route("/apply", apply, methods=["POST"], status_code=201)
# ------------------------------------------------------------------
# GET /api/jobs/my-application
# ------------------------------------------------------------------
@router.get("/my-application")
async def my_application(user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"""SELECT id, status, admin_note, created_at
FROM job_applications WHERE user_id=?
ORDER BY created_at DESC LIMIT 1""",
(user["id"],)
).fetchone()
if not row:
return {"application": None}
return {"application": dict(row)}
# ------------------------------------------------------------------
# GET /api/jobs/luna-trial-status
# ------------------------------------------------------------------
@router.get("/luna-trial-status")
async def luna_trial_status(user=Depends(get_current_user)):
from datetime import datetime as _dt
trial = user.get("luna_trial_until")
if not trial:
return {"active": False}
remaining = (_dt.fromisoformat(trial) - _dt.utcnow()).days
return {"active": remaining > 0, "until": trial, "remaining_days": max(0, remaining)}
# ------------------------------------------------------------------
# Admin: Bewerbungen verwalten
# ------------------------------------------------------------------
@router.get("/admin/applications")
async def list_applications(
status: str = "pending",
admin = Depends(require_admin),
):
where = "" if status == "alle" else "WHERE a.status=?"
params = [] if status == "alle" else [status]
with db() as conn:
rows = conn.execute(f"""
SELECT a.*, u.name AS username,
COUNT(d.id) AS doc_count
FROM job_applications a
LEFT JOIN users u ON u.id = a.user_id
LEFT JOIN job_application_docs d ON d.application_id = a.id
{where}
GROUP BY a.id
ORDER BY a.created_at DESC
""", params).fetchall()
return [dict(r) for r in rows]
@router.get("/admin/applications/{app_id}")
async def get_application(app_id: int, admin=Depends(require_admin)):
with db() as conn:
row = conn.execute(
"""SELECT a.*, u.name AS username, u.email AS user_email
FROM job_applications a
LEFT JOIN users u ON u.id = a.user_id
WHERE a.id=?""",
(app_id,)
).fetchone()
if not row:
raise HTTPException(404)
docs = conn.execute(
"SELECT id, filename, uploaded_at FROM job_application_docs WHERE application_id=?",
(app_id,)
).fetchall()
return {**dict(row), "docs": [dict(d) for d in docs]}
@router.patch("/admin/applications/{app_id}")
async def update_application(
app_id: int,
status: Optional[str] = None,
admin_note: Optional[str] = None,
admin = Depends(require_admin),
):
valid = {"pending", "reviewing", "accepted", "rejected"}
if status and status not in valid:
raise HTTPException(400, f"Ungültiger Status. Erlaubt: {valid}")
with db() as conn:
row = conn.execute(
"SELECT user_id, email, name, status FROM job_applications WHERE id=?",
(app_id,)
).fetchone()
if not row:
raise HTTPException(404)
updates: dict = {"reviewed_at": datetime.utcnow().isoformat()}
if status:
updates["status"] = status
if admin_note is not None:
updates["admin_note"] = admin_note
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(
f"UPDATE job_applications SET {set_clause} WHERE id=?",
(*updates.values(), app_id)
)
# Bei Annahme: is_social_media aktivieren + Gründer-Status
if status == "accepted" and row["user_id"]:
conn.execute(
"UPDATE users SET is_social_media=1 WHERE id=?",
(row["user_id"],)
)
founder_count = conn.execute(
"SELECT COUNT(*) FROM users WHERE is_founder=1"
).fetchone()[0]
if founder_count < 100:
conn.execute(
"UPDATE users SET is_founder=1 WHERE id=? AND is_founder=0",
(row["user_id"],)
)
# Status-Mail an Bewerber
try:
if status in ("accepted", "rejected", "reviewing"):
_send_status_mail(row["email"], row["name"], status, admin_note or "")
except Exception:
pass
return {"ok": True}
@router.get("/admin/applications/{app_id}/docs/{doc_id}")
async def download_doc(app_id: int, doc_id: int, admin=Depends(require_admin)):
with db() as conn:
doc = conn.execute(
"SELECT file_path, filename FROM job_application_docs WHERE id=? AND application_id=?",
(doc_id, app_id)
).fetchone()
if not doc or not os.path.exists(doc["file_path"]):
raise HTTPException(404)
return FileResponse(doc["file_path"], filename=doc["filename"])
def _send_status_mail(email: str, name: str, status: str, note: str):
import asyncio
texts = {
"reviewing": ("Wir schauen uns deine Bewerbung genauer an 🐾",
f"<p>Hallo <b>{name}</b>,</p><p>wir schauen uns deine Bewerbung gerade genauer an. Wir melden uns bald!</p>"),
"accepted": ("Herzlichen Glückwunsch — du bist dabei! 🎉",
f"<p>Hallo <b>{name}</b>,</p><p>wir freuen uns, dir mitzuteilen: <b>du bist unser neuer Social-Media-Manager/in für Ban Yaro!</b><br>Du erhältst außerdem den <b>Gründer-Status</b> in unserer Community. Willkommen im Team!</p>"),
"rejected": ("Deine Bewerbung bei Ban Yaro",
f"<p>Hallo <b>{name}</b>,</p><p>vielen Dank für deine Bewerbung. Leider hat es diesmal nicht geklappt — aber wir wünschen dir alles Gute!</p>"),
}
subj, body_start = texts.get(status, ("Update zu deiner Bewerbung", f"<p>Hallo {name},</p>"))
note_html = f'<div style="background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;border-radius:0 8px 8px 0;margin:12px 0">{note}</div>' if note else ""
body = body_start + note_html
async def _send():
await send_email(email, subj, email_html(body, cta_url="https://banyaro.app", cta_label="Zur App"), subj)
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(_send())
else:
loop.run_until_complete(_send())
except Exception:
pass

View file

@ -1,111 +1,245 @@
"""BAN YARO — Hunde-Filme Routes""" """BAN YARO — Hunde-Filme Routes"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
from database import db from database import db
from auth import get_current_user, get_current_user_optional from auth import get_current_user, get_current_user_optional, require_admin
router = APIRouter() router = APIRouter()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Hardcoded Film-Daten # Seed-Daten — werden beim ersten Start in die DB geschrieben
# ------------------------------------------------------------------ # ------------------------------------------------------------------
FILME = [ _SEED_FILME = [
{"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "bewertung_avg": 4.2}, # ── Originalbestand ──────────────────────────────────────────────
{"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "bewertung_avg": 4.0}, {"id": "lassie", "titel": "Lassie", "jahr": 1943, "genre": "Familie", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Klassiker schlechthin. Lassie findet immer nach Hause.", "bild_emoji": "🐕", "imdb_rating": 7.0},
{"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "bewertung_avg": 4.5}, {"id": "benji", "titel": "Benji", "jahr": 1974, "genre": "Familie", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Ein herrenloser Hund rettet Kinder aus den Händen von Entführern.", "bild_emoji": "🐾", "imdb_rating": 6.4},
{"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "bewertung_avg": 4.8}, {"id": "marley-and-me", "titel": "Marley & Ich", "jahr": 2008, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Der chaotischste, aber liebste Labrador der Welt. Achtung: Taschentücher bereithalten.", "bild_emoji": "😭", "imdb_rating": 7.1},
{"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "bewertung_avg": 4.3}, {"id": "hachiko", "titel": "Hachi: A Dog's Tale", "jahr": 2009, "genre": "Drama", "typ": "film", "hund_rasse": "Akita", "stirbt_der_hund": True, "beschreibung": "Basiert auf der wahren Geschichte des treuen Akita Hachikō. Starke emotionale Wirkung.", "bild_emoji": "💔", "imdb_rating": 8.1},
{"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "bewertung_avg": 3.8}, {"id": "101-dalmatiner", "titel": "101 Dalmatiner", "jahr": 1961, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Dalmatiner", "stirbt_der_hund": False, "beschreibung": "Dalmatiner-Welpen vs. die böse Cruella de Vil. Animationsklassiker.", "bild_emoji": "🐡", "imdb_rating": 7.2},
{"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi/Serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "bewertung_avg": 4.1}, {"id": "beethoven", "titel": "Beethoven", "jahr": 1992, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Riesiger Bernhardiner bringt Chaos ins Familienleben. Mehrere Fortsetzungen.", "bild_emoji": "🎵", "imdb_rating": 5.9},
{"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "bewertung_avg": 4.0}, {"id": "rex", "titel": "Kommissar Rex", "jahr": 1994, "genre": "Krimi", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Österreichische Krimiserie. Rex löst gemeinsam mit seinem Herrchen Verbrechen.", "bild_emoji": "🔍", "imdb_rating": 7.5},
{"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "bewertung_avg": 3.5}, {"id": "old-yeller", "titel": "Old Yeller", "jahr": 1957, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Amerikanischer Filmklassiker. Berühmtestes Filmende der Hundfilm-Geschichte.", "bild_emoji": "🌾", "imdb_rating": 7.3},
{"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus. Kontroversiell beliebt.", "bild_emoji": "💣", "bewertung_avg": 4.6}, {"id": "buddy", "titel": "Air Bud", "jahr": 1997, "genre": "Familie/Sport", "typ": "film", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Hund spielt Basketball. Klingt absurd, wurde ein Hit.", "bild_emoji": "🏀", "imdb_rating": 5.7},
{"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "bewertung_avg": 4.4}, {"id": "john-wick", "titel": "John Wick", "jahr": 2014, "genre": "Action", "typ": "film", "hund_rasse": "Beagle", "stirbt_der_hund": True, "beschreibung": "Achtung Spoiler: Der Hund stirbt am Anfang. Das löst die ganze Geschichte aus.", "bild_emoji": "💣", "imdb_rating": 7.4},
{"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "bewertung_avg": 4.3}, {"id": "isle-of-dogs", "titel": "Isle of Dogs", "jahr": 2018, "genre": "Animation", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Wes Anderson Stopmotion-Meisterwerk. Alle Hunde Japans auf einer Insel verbannt.", "bild_emoji": "🏝️", "imdb_rating": 7.9},
{"id": "eight-below", "titel": "8 Below", "jahr": 2006, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Schlittenhunde", "stirbt_der_hund": True, "beschreibung": "Basiert auf wahren Ereignissen. Schlittenhunde überleben die Antarktis. Einige nicht.", "bild_emoji": "❄️", "imdb_rating": 7.3},
# ── Animation / Kinder ──────────────────────────────────────────
{"id": "lady-and-the-tramp", "titel": "Susi und Strolch", "originaltitel": "Lady and the Tramp", "jahr": 1955, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Cocker Spaniel / Mischling", "stirbt_der_hund": False, "beschreibung": "Disney-Klassiker mit der berühmtesten Spaghetti-Szene der Filmgeschichte.", "bild_emoji": "🍝", "imdb_rating": 7.3, "streaming": "Disney+"},
{"id": "fox-and-the-hound", "titel": "Cap und Capper", "originaltitel": "The Fox and the Hound", "jahr": 1981, "genre": "Animation/Familie", "typ": "film", "hund_rasse": "Bloodhound", "stirbt_der_hund": False, "beschreibung": "Emotionaler Disney-Film über Freundschaft zwischen Fuchs und Jagdhund — und wie die Welt sie trennt.", "bild_emoji": "🦊", "imdb_rating": 7.2, "streaming": "Disney+"},
{"id": "balto", "titel": "Balto", "jahr": 1995, "genre": "Animation/Abenteuer", "typ": "film", "hund_rasse": "Husky/Wolf-Mischling", "stirbt_der_hund": False, "beschreibung": "1925 brachte Schlittenhund Balto lebensrettende Medizin nach Nome, Alaska. Basiert auf einer wahren Heldengeschichte.", "bild_emoji": "🐺", "imdb_rating": 7.1, "streaming": "Amazon Prime"},
{"id": "bolt", "titel": "Bolt — Ein Hund für alle Fälle", "originaltitel": "Bolt", "jahr": 2008, "genre": "Animation/Abenteuer", "typ": "film", "hund_rasse": "Weißer Schäferhund", "stirbt_der_hund": False, "beschreibung": "Ein TV-Superhund glaubt, seine Kräfte seien echt, und reist abenteuerlich quer durch Amerika.", "bild_emoji": "", "imdb_rating": 6.8, "streaming": "Disney+"},
{"id": "frankenweenie", "titel": "Frankenweenie", "jahr": 2012, "genre": "Animation/Horrorkomödie","typ": "film","hund_rasse": "Bullterrier", "stirbt_der_hund": True, "beschreibung": "Tim Burtons Stop-Motion-Meisterwerk: Ein Junge erweckt seinen toten Hund mit Wissenschaft wieder zum Leben.", "bild_emoji": "🧟", "imdb_rating": 6.9, "streaming": "Disney+"},
{"id": "secret-life-of-pets", "titel": "Pets — Geheimes Leben der Haustiere","originaltitel": "The Secret Life of Pets","jahr": 2016,"genre": "Animation/Komödie","typ": "film","hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Was machen unsere Haustiere, wenn wir nicht zu Hause sind? Rasante Antwort mit Witz und Charme.", "bild_emoji": "🏠", "imdb_rating": 6.5, "streaming": "Amazon Prime"},
{"id": "plague-dogs", "titel": "The Plague Dogs", "jahr": 1982, "genre": "Animation/Drama", "typ": "film", "hund_rasse": "Labrador / Mischling", "stirbt_der_hund": True, "beschreibung": "Düsterer Animationsfilm für Erwachsene: Zwei Hunde fliehen aus einem Tierversuchs-Labor. Brutal ehrlich, nach Richard Adams.", "bild_emoji": "🚫", "imdb_rating": 7.7},
{"id": "paw-patrol-movie", "titel": "PAW Patrol: Der Kinofilm", "originaltitel": "PAW Patrol: The Movie", "jahr": 2021, "genre": "Animation/Kinder", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Die beliebten TV-Rettungshunde auf der großen Leinwand. Für die jüngsten Fans ein Pflichtprogramm.", "bild_emoji": "🚒", "imdb_rating": 6.1, "streaming": "Amazon Prime"},
# ── Klassiker vor 1980 ──────────────────────────────────────────
{"id": "the-thin-man", "titel": "Der dünne Mann", "originaltitel": "The Thin Man", "jahr": 1934, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Drahthaariger Foxterrier", "stirbt_der_hund": False, "beschreibung": "Hollywood-Klassiker mit Nick und Nora Charles — und Asta, dem witzigsten Hund der Filmgeschichte. Mehrere Fortsetzungen.", "bild_emoji": "🍸", "imdb_rating": 7.9},
{"id": "lassie-come-home", "titel": "Lassie kehrt heim", "originaltitel": "Lassie Come Home", "jahr": 1943, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Der Originalfilm: Ein armes Farmkind muss seinen geliebten Collie verkaufen — Lassie findet trotzdem heim.", "bild_emoji": "🏡", "imdb_rating": 7.1},
{"id": "incredible-journey", "titel": "Die unglaubliche Reise", "originaltitel": "The Incredible Journey", "jahr": 1963, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Labrador", "stirbt_der_hund": False, "beschreibung": "Zwei Hunde und eine Katze meistern 400 km kanadische Wildnis — Disney-Abenteuer nach dem Roman von Sheila Burnford.", "bild_emoji": "🗺️", "imdb_rating": 7.0},
{"id": "greyfriars-bobby", "titel": "Greyfriars Bobby", "jahr": 1961, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Skye Terrier", "stirbt_der_hund": False, "beschreibung": "Basiert auf der wahren Geschichte des Terriers, der 14 Jahre das Grab seines Herrchens in Edinburgh bewachte.", "bild_emoji": "", "imdb_rating": 7.2, "streaming": "Disney+"},
{"id": "sounder", "titel": "Sounder", "jahr": 1972, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Coonhound", "stirbt_der_hund": False, "beschreibung": "Oscar-nominiertes Drama über eine schwarze Farmfamilie in der Great Depression. Ihr Hund Sounder ist das Herz der Geschichte.", "bild_emoji": "🌾", "imdb_rating": 7.5},
{"id": "where-red-fern-grows","titel": "Wo der rote Farn wächst", "originaltitel": "Where the Red Fern Grows", "jahr": 1974, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Coonhound", "stirbt_der_hund": True, "beschreibung": "Ein Junge spart jahrelang für zwei Jagdhunde. Kultfilm der amerikanischen Kindheit — das Ende lässt kaum jemanden trocken.", "bild_emoji": "🌿", "imdb_rating": 6.9},
{"id": "milo-and-otis", "titel": "Milo und Otis", "originaltitel": "The Adventures of Milo and Otis","jahr": 1986,"genre": "Abenteuer/Familie","typ": "film", "hund_rasse": "Mops", "stirbt_der_hund": False, "beschreibung": "Japanischer Realfilm mit Katze und Hund auf großer Abenteuerreise. Für Kinder ein Klassiker, für Erwachsene nostalgisches Heimweh.", "bild_emoji": "🐾", "imdb_rating": 6.9},
{"id": "umberto-d", "titel": "Umberto D.", "jahr": 1952, "genre": "Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Meisterwerk des italienischen Neorealismus: Ein alter Rentner und sein Hund kämpfen würdevoll gegen Armut in Rom.", "bild_emoji": "🇮🇹", "imdb_rating": 8.1, "streaming": "Mubi"},
# ── Wahre Geschichten ───────────────────────────────────────────
{"id": "homeward-bound", "titel": "Auf dem Weg nach Hause", "originaltitel": "Homeward Bound", "jahr": 1993, "genre": "Abenteuer/Familie", "typ": "film", "hund_rasse": "Bullterrier / Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Zwei Hunde und eine Katze kämpfen sich mit Stimmen durch die amerikanische Wildnis nach Hause. Remake des Klassikers.", "bild_emoji": "🏔️", "imdb_rating": 7.0, "streaming": "Disney+"},
{"id": "togo", "titel": "Togo", "jahr": 2019, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Sibirischer Husky", "stirbt_der_hund": False, "beschreibung": "Die unbekannte Geschichte hinter dem Balto-Mythos: Der echte Held des Serum-Runs 1925 war Togo. Außergewöhnlicher Disney+-Film.", "bild_emoji": "🛷", "imdb_rating": 7.9, "streaming": "Disney+"},
{"id": "red-dog", "titel": "Red Dog", "jahr": 2011, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Kelpie", "stirbt_der_hund": True, "beschreibung": "Australischer Kultfilm über einen echten Wanderhund, der eine Minengemeinschaft im Outback zusammenbrachte. Rauh und herzlich.", "bild_emoji": "🦘", "imdb_rating": 7.3, "streaming": "Amazon Prime"},
{"id": "megan-leavey", "titel": "Megan Leavey", "jahr": 2017, "genre": "Biopic/Drama", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Die wahre Geschichte einer US-Marine und ihres Sprengstoff-Suchhundes Rex im Irak-Einsatz. Kate Mara in einer ihrer stärksten Rollen.", "bild_emoji": "🎖️", "imdb_rating": 7.1, "streaming": "Amazon Prime"},
{"id": "arthur-the-king", "titel": "Arthur der König", "originaltitel": "Arthur the King", "jahr": 2024, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Mark Wahlberg und ein streunender Hund meistern gemeinsam ein Extremrennen durch die Dominikanische Republik. Inspiriert von wahren Ereignissen.","bild_emoji": "🏆", "imdb_rating": 7.0, "streaming": "Amazon Prime"},
{"id": "rescued-by-ruby", "titel": "Gerettet von Ruby", "originaltitel": "Rescued by Ruby", "jahr": 2022, "genre": "Biopic/Familie", "typ": "film", "hund_rasse": "Australian Shepherd / Border Collie","stirbt_der_hund": False,"beschreibung": "Ein Polizist und ein Tierheim-Hund retten sich gegenseitig — wahre Geschichte aus Rhode Island.", "bild_emoji": "🌟", "imdb_rating": 7.2, "streaming": "Netflix"},
{"id": "my-dog-skip", "titel": "Mein Hund Skip", "originaltitel": "My Dog Skip", "jahr": 2000, "genre": "Familie/Drama", "typ": "film", "hund_rasse": "Jack Russell Terrier", "stirbt_der_hund": True, "beschreibung": "Die Coming-of-Age-Geschichte eines einsamen Jungen im Mississippi der 1940er, der durch seinen Hund Skip Freundschaft findet.", "bild_emoji": "📚", "imdb_rating": 7.0},
# ── Arbeitshunde / Polizeihunde ─────────────────────────────────
{"id": "turner-and-hooch", "titel": "Turner & Hooch", "jahr": 1989, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Dogue de Bordeaux", "stirbt_der_hund": True, "beschreibung": "Tom Hanks als ordentlicher Detective trifft auf Hooch, den sabbernden Chaoshund. Buddy-Cop-Klassiker mit überraschend emotionalem Ende.", "bild_emoji": "🕵️", "imdb_rating": 6.2, "streaming": "Disney+"},
{"id": "k9", "titel": "K-9", "jahr": 1989, "genre": "Krimi/Komödie", "typ": "film", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Jim Belushi als Drogenfahnder bekommt zwangsweise den eigenwilligen Schäferhund Jerry als Partner. Klassiker des Buddy-Cop-Genres.", "bild_emoji": "🚔", "imdb_rating": 6.2},
{"id": "max", "titel": "Max", "jahr": 2015, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Ein Kriegshund aus Afghanistan wird nach dem Tod seines Handlers von dessen Familie adoptiert. Über Trauma und Vertrauen.", "bild_emoji": "🎗️", "imdb_rating": 6.6, "streaming": "Amazon Prime"},
{"id": "dog-2022", "titel": "Dog", "jahr": 2022, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Belgischer Schäferhund (Malinois)", "stirbt_der_hund": False, "beschreibung": "Channing Tatum fährt mit einem traumatisierten Kriegshund quer durch Amerika — roh, komisch und berührend.", "bild_emoji": "🚗", "imdb_rating": 6.5, "streaming": "Amazon Prime"},
{"id": "quill", "titel": "Quill — Ein Führhund", "originaltitel": "Quill: The Life of a Guide Dog","jahr": 2004,"genre": "Drama/Familie", "typ": "film", "hund_rasse": "Labrador", "stirbt_der_hund": True, "beschreibung": "Japanischer Film über das Leben des Führhundes Quill vom Welpen bis zum Tod. Zeigt die unersetzliche Arbeit von Blindenführhunden.", "bild_emoji": "👁️", "imdb_rating": 7.1},
# ── Komödien ────────────────────────────────────────────────────
{"id": "beethoven-2", "titel": "Beethoven's 2nd", "jahr": 1993, "genre": "Familie/Komödie", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": False, "beschreibung": "Beethoven verliebt sich und bekommt Nachwuchs — vier chaotische Welpen bringen die Familie erneut an den Rand des Nervenzusammenbruchs.", "bild_emoji": "🐶", "imdb_rating": 5.4},
{"id": "dog-days-2018", "titel": "Dog Days", "jahr": 2018, "genre": "Komödie/Romanze", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Mehrere Angelenos und ihre Hunde, deren Leben sich charmant verflechten. Leichte Feel-Good-Komödie mit Vanessa Hudgens.", "bild_emoji": "☀️", "imdb_rating": 6.3},
{"id": "as-good-as-it-gets", "titel": "Besser geht's nicht", "originaltitel": "As Good as It Gets", "jahr": 1997, "genre": "Komödie/Drama", "typ": "film", "hund_rasse": "Griffon Bruxellois", "stirbt_der_hund": False, "beschreibung": "Jack Nicholson als Misanthrop, der durch einen kleinen Hund namens Verdell sein Herz entdeckt. Oscar-Gewinner, zeitlos witzig.", "bild_emoji": "💊", "imdb_rating": 7.7, "streaming": "Amazon Prime"},
{"id": "eat-pray-bark", "titel": "Eat Pray Bark", "jahr": 2026, "genre": "Komödie", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Deutsche Komödie über fünf exzentrische Hundebesitzer, die gemeinsam einen Hundetrainer in den Tiroler Bergen aufsuchen. Top 10 in 49 Netflix-Ländern.", "bild_emoji": "🏔️", "imdb_rating": None, "streaming": "Netflix"},
{"id": "the-artist", "titel": "The Artist", "jahr": 2011, "genre": "Drama/Komödie", "typ": "film", "hund_rasse": "Jack Russell Terrier", "stirbt_der_hund": False, "beschreibung": "Oscar-Gewinner als Stummfilm-Hommage. Uggie der Jack Russell stahl allen die Show und gewann den Palm Dog Award in Cannes.", "bild_emoji": "🎬", "imdb_rating": 7.8, "streaming": "Amazon Prime"},
# ── Thriller / Action / Horror ──────────────────────────────────
{"id": "cujo", "titel": "Cujo", "jahr": 1983, "genre": "Horror/Thriller", "typ": "film", "hund_rasse": "Bernhardiner", "stirbt_der_hund": True, "beschreibung": "Stephen Kings Roman verfilmt: Ein tollwütiger Bernhardiner terrorisiert eine Mutter und ihr Kind in einem Auto. Klassiker des 80er-Horror.", "bild_emoji": "🩸", "imdb_rating": 6.1, "streaming": "Amazon Prime"},
{"id": "white-god", "titel": "White God — Hund ohne Gnade","originaltitel": "White God", "jahr": 2014, "genre": "Drama/Thriller", "typ": "film", "hund_rasse": "Mischling", "stirbt_der_hund": True, "beschreibung": "Ungarischer Arthouse-Thriller mit 250 echten Straßenhunden. Ein Mädchen sucht seinen Hund — während die Hunde Rache nehmen. Cannes-Preis.", "bild_emoji": "🔴", "imdb_rating": 6.8},
{"id": "dogman-2018", "titel": "Dogman", "jahr": 2018, "genre": "Krimi/Drama", "typ": "film", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Matteo Garrones preisgekrönter Film: Ein stiller Hundegroomer verstrickt sich mit einem brutalen Kriminellen. Cannes-Gewinner 2018.", "bild_emoji": "✂️", "imdb_rating": 7.2, "streaming": "Amazon Prime"},
{"id": "call-of-the-wild", "titel": "Ruf der Wildnis", "originaltitel": "The Call of the Wild", "jahr": 2020, "genre": "Abenteuer/Drama", "typ": "film", "hund_rasse": "Saint Bernard Mix (CGI)", "stirbt_der_hund": False, "beschreibung": "Jack Londons Klassiker mit Harrison Ford: Hund Buck wandert vom Salon-Leben in die Wildnis des Klondike. Episch.", "bild_emoji": "🌲", "imdb_rating": 6.7, "streaming": "Disney+"},
# ── Deutsche / österreichische Produktionen ─────────────────────
{"id": "lassie-neues-abenteuer","titel": "Lassie — Ein neues Abenteuer","jahr": 2023, "genre": "Familie/Abenteuer","typ": "film", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Deutsche Neuinterpretation: Lassie hilft Kindern dabei, mysteriöse Hundesdiebstähle aufzudecken. Für Kinder und Familien.", "bild_emoji": "🐕", "imdb_rating": 5.6},
# ── Neuere Serien ───────────────────────────────────────────────
{"id": "turner-hooch-serie", "titel": "Turner & Hooch (Serie)", "originaltitel": "Turner & Hooch", "jahr": 2021, "genre": "Krimi/Komödie", "typ": "serie", "hund_rasse": "Dogue de Bordeaux", "stirbt_der_hund": False, "beschreibung": "Disney+-Sequel zur Filmklassik: Der Sohn des Originals detektiviert mit Hoochs Nachfolger. Nach einer Staffel abgesetzt.", "bild_emoji": "📺", "imdb_rating": 6.6, "streaming": "Disney+"},
{"id": "healing-powers-of-dude","titel": "Dude, mein Hund", "originaltitel": "The Healing Powers of Dude", "jahr": 2020, "genre": "Komödie/Familie", "typ": "serie", "hund_rasse": "Labradoodle", "stirbt_der_hund": False, "beschreibung": "Netflix-Jugendserie: Ein Junge mit sozialer Angststörung und sein emotionaler Support-Hund meistern gemeinsam die Middle School.", "bild_emoji": "💙", "imdb_rating": 6.6, "streaming": "Netflix"},
{"id": "hudson-rex", "titel": "Hudson & Rex", "jahr": 2019, "genre": "Krimi", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Kanadische Neuauflage von Kommissar Rex: Detective Hudson und sein Schäferhund Rex lösen in Neufundland Verbrechen. Läuft seit 2019.", "bild_emoji": "🍁", "imdb_rating": 7.4},
# ── Klassische Serien ───────────────────────────────────────────
{"id": "lassie-serie", "titel": "Lassie (TV-Serie)", "originaltitel": "Lassie", "jahr": 1954, "genre": "Familie/Abenteuer", "typ": "serie", "hund_rasse": "Collie", "stirbt_der_hund": False, "beschreibung": "Die legendäre CBS-Serie, die 20 Jahre lief und Generationen prägte. Lassies wöchentliche Rettungsaktionen wurden zum Inbegriff des Treue-Hundes.", "bild_emoji": "📡", "imdb_rating": 7.5},
{"id": "rin-tin-tin-serie", "titel": "Rin Tin Tin", "originaltitel": "The Adventures of Rin Tin Tin","jahr": 1954, "genre": "Western/Familie", "typ": "serie", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Legendäre 1950er-Western-Serie. Ein Waisenjunge und sein Schäferhund helfen der US-Kavallerie im Wilden Westen.", "bild_emoji": "🤠", "imdb_rating": 7.0},
# ── Dokumentationen ─────────────────────────────────────────────
{"id": "dogs-netflix", "titel": "Dogs", "jahr": 2018, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Herzzerreißende Netflix-Dokuserie über die Bindung zwischen Hunden und Menschen weltweit. Sechs Episoden, Tränen garantiert.", "bild_emoji": "❤️", "imdb_rating": 8.0, "streaming": "Netflix"},
{"id": "pick-of-the-litter", "titel": "Pick of the Litter", "jahr": 2018, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Labrador", "stirbt_der_hund": False, "beschreibung": "Ein Labrador-Wurf wird zur Führhundausbildung bestimmt. Nicht alle schaffen es — spannend wie ein Spielfilm.", "bild_emoji": "🎗️", "imdb_rating": 7.6, "streaming": "Amazon Prime"},
{"id": "inside-mind-of-dog", "titel": "Im Kopf des Hundes", "originaltitel": "Inside the Mind of a Dog", "jahr": 2024, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Netflix-Wissenschaftsdoku: Was wissen Hunde wirklich über uns? Rob Lowe erzählt, Forscher erklären die Kognition unserer Vierbeiner.", "bild_emoji": "🧠", "imdb_rating": 7.2, "streaming": "Netflix"},
{"id": "stray-doku", "titel": "Stray", "jahr": 2020, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Mischling", "stirbt_der_hund": False, "beschreibung": "Poetische Doku aus Hundeperspektive: Die Kamera folgt drei Straßenhunden durch Istanbul. Philosophisch, still und ungemein berührend.", "bild_emoji": "🕌", "imdb_rating": 6.9, "streaming": "Amazon Prime"},
{"id": "dog-by-dog", "titel": "Dog by Dog", "jahr": 2015, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Verschiedene", "stirbt_der_hund": False, "beschreibung": "Erschütternde Doku über den milliardenschweren Welpen-Industrie-Komplex in den USA. Folgt dem Geldfluss hinter Puppy Mills.", "bild_emoji": "💰", "imdb_rating": 8.8, "streaming": "Netflix"},
{"id": "gunthers-millions", "titel": "Günthers Millionen", "originaltitel": "Gunther's Millions", "jahr": 2023, "genre": "Dokumentation", "typ": "doku", "hund_rasse": "Deutscher Schäferhund", "stirbt_der_hund": False, "beschreibung": "Die absurde Netflix-Doku: Erbte ein Schäferhund wirklich eine halbe Milliarde Euro? Die Wahrheit ist noch seltsamer.", "bild_emoji": "💎", "imdb_rating": 5.6, "streaming": "Netflix"},
# ── Weitere ─────────────────────────────────────────────────────
{"id": "a-dogs-purpose", "titel": "Bailey — Ein Freund fürs Leben","originaltitel": "A Dog's Purpose", "jahr": 2017, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Verschiedene (Labrador, Corgi u.a.)","stirbt_der_hund": True, "beschreibung": "Ein Hund wird mehrfach wiedergeboren und sucht in jeder Inkarnation nach seinem Sinn. Taschentücher-Pflicht.", "bild_emoji": "🔄", "imdb_rating": 7.3, "streaming": "Amazon Prime"},
{"id": "a-dogs-journey", "titel": "Bailey 2", "originaltitel": "A Dog's Journey", "jahr": 2019, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Beagle / Bernhardiner u.a.", "stirbt_der_hund": True, "beschreibung": "Fortsetzung: Bailey beschützt in mehreren Leben die Enkelin seines Herrchens. Emotional und rührend.", "bild_emoji": "🔄", "imdb_rating": 7.5, "streaming": "Amazon Prime"},
{"id": "art-of-racing", "titel": "Enzo und die wundersame Welt der Menschen","originaltitel": "The Art of Racing in the Rain","jahr": 2019,"genre": "Drama","typ": "film","hund_rasse": "Golden Retriever", "stirbt_der_hund": True, "beschreibung": "Ein Golden Retriever erzählt seine Lebensgeschichte. Philosophisch, witzig, herzzerreißend — Kevin Costner leiht ihm die Stimme.", "bild_emoji": "🏎️", "imdb_rating": 7.6, "streaming": "Disney+"},
{"id": "dog-gone", "titel": "Dog Gone", "originaltitel": "Dog Gone", "jahr": 2023, "genre": "Drama/Familie", "typ": "film", "hund_rasse": "Golden Retriever", "stirbt_der_hund": False, "beschreibung": "Vater und Sohn suchen auf dem Appalachian Trail ihren verlorenen kranken Hund — und finden dabei zueinander. Wahre Geschichte.", "bild_emoji": "🥾", "imdb_rating": 6.1, "streaming": "Netflix"},
{"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},
] ]
PROMIS = [ _SEED_PROMIS = [
{"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre lang täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"}, {"name": "Hachikō", "rasse": "Akita Inu", "bekannt_fuer": "9 Jahre täglich auf seinen verstorbenen Herrchen am Bahnhof Shibuya gewartet. Statue in Tokio.", "emoji": "🗿"},
{"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund", "bekannt_fuer": "Filmhund der 1920er-Jahre. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"}, {"name": "Rin Tin Tin", "rasse": "Deutscher Schäferhund","bekannt_fuer": "Filmhund der 1920er. Rettete Warner Bros. vor dem Bankrott. Erster Hundestar Hollywoods.", "emoji": "🎬"},
{"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Wurde zur sowjetischen Weltraumpionierin.", "emoji": "🚀"}, {"name": "Laika", "rasse": "Mischling", "bekannt_fuer": "Erstes Lebewesen im Weltall (Sputnik 2, 1957). Sowjetische Weltraumpionierin.", "emoji": "🚀"},
{"name": "Endal", "rasse": "Labrador", "bekannt_fuer": "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", "emoji": "💳"}, {"name": "Endal", "rasse": "Labrador", "bekannt_fuer": "Assistenzhund in England. Erster Hund der eine EC-Karte am Geldautomaten benutzte.", "emoji": "💳"},
{"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (18001812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"}, {"name": "Barry", "rasse": "Bernhardiner", "bekannt_fuer": "Legendärer Rettungshund der Alpen (18001812). Soll 40 Menschen das Leben gerettet haben.", "emoji": "🏔️"},
{"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre lang das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": ""}, {"name": "Greyfriars Bobby", "rasse": "Skye Terrier", "bekannt_fuer": "14 Jahre das Grab seines Herrchens in Edinburgh bewacht. Statue und Pub benannt nach ihm.", "emoji": ""},
{"name": "Balto", "rasse": "Siberian Husky", "bekannt_fuer": "Führte 1925 den letzten Abschnitt des Serum-Runs nach Nome, Alaska. Statue im Central Park New York.", "emoji": "🛷"},
{"name": "Togo", "rasse": "Siberian Husky", "bekannt_fuer": "Der echte Held des Serum-Runs 1925 — legte die schwierigste Strecke zurück, blieb aber lange unbekannt.", "emoji": "🏅"},
{"name": "Asta", "rasse": "Drahthaariger Foxterrier","bekannt_fuer": "Filmhund in der 'Dünner Mann'-Reihe (19341947). Hollywood-Ikone der klassischen Ära.", "emoji": "🎩"},
{"name": "Lassie", "rasse": "Rough Collie", "bekannt_fuer": "Meistverfilmter Hund der Geschichte. Erster Vierbeiner mit einem Stern auf dem Hollywood Walk of Fame.", "emoji": ""},
] ]
def seed_movies():
"""Füllt die movies-Tabelle beim ersten Start (idempotent per INSERT OR IGNORE)."""
import logging
logger = logging.getLogger(__name__)
with db() as conn:
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.")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Schemas # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class FilmVoteRequest(BaseModel): class FilmVoteRequest(BaseModel):
bewertung: int # 15 bewertung: int # 15
class HundDesMonatsVoteRequest(BaseModel): class HundDesMonatsVoteRequest(BaseModel):
dog_id: int dog_id: int
class MovieCreate(BaseModel):
id: str
titel: str
originaltitel: Optional[str] = None
jahr: Optional[int] = None
genre: Optional[str] = None
typ: str = "film"
hund_rasse: Optional[str] = None
stirbt_der_hund: bool = False
beschreibung: Optional[str] = None
bild_emoji: str = "🐾"
imdb_rating: Optional[float] = None
streaming: Optional[str] = None
class MovieUpdate(BaseModel):
titel: Optional[str] = None
originaltitel: Optional[str] = None
jahr: Optional[int] = None
genre: Optional[str] = None
typ: Optional[str] = None
hund_rasse: Optional[str] = None
stirbt_der_hund: Optional[bool] = None
beschreibung: Optional[str] = None
bild_emoji: Optional[str] = None
imdb_rating: Optional[float] = None
streaming: Optional[str] = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# GET /api/movies/filme — Film-Liste mit optionaler User-Bewertung # GET /api/movies/filme
# ------------------------------------------------------------------ # ------------------------------------------------------------------
_SORT_COLS = {
"titel": "m.titel ASC",
"jahr_desc": "m.jahr DESC",
"jahr_asc": "m.jahr ASC",
"imdb": "m.imdb_rating DESC",
"bewertung": "community_avg DESC",
"default": "m.sort_order ASC, m.jahr DESC",
}
@router.get("/filme") @router.get("/filme")
async def get_filme(user=Depends(get_current_user_optional)): async def get_filme(
user_ratings = {} sort: str = Query("default"),
community_avgs = {} typ: str = Query("alle"), # alle | film | serie | doku
user = Depends(get_current_user_optional),
):
order = _SORT_COLS.get(sort, _SORT_COLS["default"])
where = ""
params: list = []
if typ != "alle":
where = "WHERE m.typ = ?"
params.append(typ)
with db() as conn: with db() as conn:
if user: rows = conn.execute(f"""
rows = conn.execute( SELECT m.*,
"SELECT film_id, bewertung FROM movie_votes WHERE user_id=?", COALESCE(AVG(v.bewertung), 0) AS community_avg,
(user["id"],), COUNT(v.id) AS bewertung_cnt,
).fetchall() uv.bewertung AS user_rating
user_ratings = {r["film_id"]: r["bewertung"] for r in rows} FROM movies m
LEFT JOIN movie_votes v ON v.film_id = m.id
avg_rows = conn.execute( LEFT JOIN movie_votes uv ON uv.film_id = m.id
"SELECT film_id, AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes GROUP BY film_id" AND uv.user_id = ?
).fetchall() {where}
community_avgs = {r["film_id"]: {"avg": round(r["avg_bew"], 1), "cnt": r["cnt"]} for r in avg_rows} GROUP BY m.id
ORDER BY {order}
""", [user["id"] if user else None] + params).fetchall()
result = [] result = []
for film in FILME: for r in rows:
f = dict(film) d = dict(r)
f["user_rating"] = user_ratings.get(film["id"]) d["stirbt_der_hund"] = bool(d["stirbt_der_hund"])
if film["id"] in community_avgs: d["bewertung_avg"] = round(d["community_avg"] or 0, 1)
f["bewertung_avg"] = community_avgs[film["id"]]["avg"] result.append(d)
f["bewertung_cnt"] = community_avgs[film["id"]]["cnt"]
else:
f["bewertung_cnt"] = 0
result.append(f)
return result return result
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# POST /api/movies/filme/{film_id}/vote — Bewertung abgeben (Upsert) # POST /api/movies/filme/{film_id}/vote
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.post("/filme/{film_id}/vote") @router.post("/filme/{film_id}/vote")
async def vote_film(film_id: str, data: FilmVoteRequest, user=Depends(get_current_user)): async def vote_film(film_id: str, data: FilmVoteRequest, user=Depends(get_current_user)):
if not any(f["id"] == film_id for f in FILME):
raise HTTPException(404, "Film nicht gefunden.")
if data.bewertung < 1 or data.bewertung > 5: if data.bewertung < 1 or data.bewertung > 5:
raise HTTPException(400, "Bewertung muss zwischen 1 und 5 liegen.") raise HTTPException(400, "Bewertung muss zwischen 1 und 5 liegen.")
with db() as conn: with db() as conn:
conn.execute( if not conn.execute("SELECT 1 FROM movies WHERE id=?", (film_id,)).fetchone():
"""INSERT INTO movie_votes (user_id, film_id, bewertung) raise HTTPException(404, "Film nicht gefunden.")
conn.execute("""
INSERT INTO movie_votes (user_id, film_id, bewertung)
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung""", ON CONFLICT(user_id, film_id) DO UPDATE SET bewertung=excluded.bewertung
(user["id"], film_id, data.bewertung), """, (user["id"], film_id, data.bewertung))
)
row = conn.execute( row = conn.execute(
"SELECT AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes WHERE film_id=?", "SELECT AVG(bewertung) as avg_bew, COUNT(*) as cnt FROM movie_votes WHERE film_id=?",
(film_id,), (film_id,),
).fetchone() ).fetchone()
return { return {
"film_id": film_id, "film_id": film_id,
"bewertung_avg": round(row["avg_bew"], 1) if row["avg_bew"] else data.bewertung, "bewertung_avg": round(row["avg_bew"], 1) if row["avg_bew"] else data.bewertung,
@ -115,15 +249,60 @@ async def vote_film(film_id: str, data: FilmVoteRequest, user=Depends(get_curren
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# GET /api/movies/hund-des-monats — Top-Votes des aktuellen Monats # Admin: CRUD für Filme
# ------------------------------------------------------------------
@router.post("/filme", status_code=201)
async def create_film(data: MovieCreate, admin=Depends(require_admin)):
with db() as conn:
max_order = conn.execute("SELECT COALESCE(MAX(sort_order),0) FROM movies").fetchone()[0]
try:
conn.execute("""
INSERT INTO movies (id, titel, originaltitel, jahr, genre, typ, hund_rasse,
stirbt_der_hund, beschreibung, bild_emoji, imdb_rating, streaming, sort_order)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (data.id, data.titel, data.originaltitel, data.jahr, data.genre, data.typ,
data.hund_rasse, 1 if data.stirbt_der_hund else 0, data.beschreibung,
data.bild_emoji, data.imdb_rating, data.streaming, max_order + 1))
except Exception:
raise HTTPException(400, "Film-ID bereits vorhanden.")
return {"ok": True}
@router.patch("/filme/{film_id}")
async def update_film(film_id: str, data: MovieUpdate, admin=Depends(require_admin)):
updates = {k: v for k, v in data.model_dump(exclude_none=True).items()}
if "stirbt_der_hund" in updates:
updates["stirbt_der_hund"] = 1 if updates["stirbt_der_hund"] else 0
if not updates:
return {"ok": True}
set_clause = ", ".join(f"{k}=?" for k in updates)
with db() as conn:
conn.execute(f"UPDATE movies SET {set_clause} WHERE id=?", (*updates.values(), film_id))
return {"ok": True}
@router.delete("/filme/{film_id}")
async def delete_film(film_id: str, admin=Depends(require_admin)):
with db() as conn:
conn.execute("DELETE FROM movies WHERE id=?", (film_id,))
return {"ok": True}
# ------------------------------------------------------------------
# GET /api/movies/promis — Berühmte Hunde (aus Seed-Daten)
# ------------------------------------------------------------------
@router.get("/promis")
async def get_promis():
return _SEED_PROMIS
# ------------------------------------------------------------------
# Hund des Monats
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.get("/hund-des-monats") @router.get("/hund-des-monats")
async def get_hund_des_monats(user=Depends(get_current_user_optional)): async def get_hund_des_monats(user=Depends(get_current_user_optional)):
monat = datetime.now().strftime("%Y-%m") monat = datetime.now().strftime("%Y-%m")
with db() as conn: with db() as conn:
rows = conn.execute( rows = conn.execute("""
"""SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name, SELECT d.id, d.name, d.rasse, d.foto_url, u.name as besitzer_name,
COUNT(v.id) as stimmen COUNT(v.id) as stimmen
FROM hund_des_monats_votes v FROM hund_des_monats_votes v
JOIN dogs d ON d.id = v.dog_id JOIN dogs d ON d.id = v.dog_id
@ -131,10 +310,8 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)):
WHERE v.monat = ? WHERE v.monat = ?
GROUP BY v.dog_id GROUP BY v.dog_id
ORDER BY stimmen DESC ORDER BY stimmen DESC
LIMIT 10""", LIMIT 10
(monat,), """, (monat,)).fetchall()
).fetchall()
user_vote = None user_vote = None
if user: if user:
row = conn.execute( row = conn.execute(
@ -143,43 +320,25 @@ async def get_hund_des_monats(user=Depends(get_current_user_optional)):
).fetchone() ).fetchone()
if row: if row:
user_vote = row["dog_id"] user_vote = row["dog_id"]
return {"monat": monat, "top": [dict(r) for r in rows], "user_vote": user_vote}
return {
"monat": monat,
"top": [dict(r) for r in rows],
"user_vote": user_vote,
}
# ------------------------------------------------------------------
# POST /api/movies/hund-des-monats/vote — Abstimmen (Auth required)
# ------------------------------------------------------------------
@router.post("/hund-des-monats/vote") @router.post("/hund-des-monats/vote")
async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)): async def vote_hund_des_monats(data: HundDesMonatsVoteRequest, user=Depends(get_current_user)):
monat = datetime.now().strftime("%Y-%m") monat = datetime.now().strftime("%Y-%m")
with db() as conn: with db() as conn:
# Prüfen ob Hund existiert und entweder dem User gehört oder öffentlich ist dog = conn.execute("SELECT id, user_id, is_public FROM dogs WHERE id=?", (data.dog_id,)).fetchone()
dog = conn.execute(
"SELECT id, user_id, is_public FROM dogs WHERE id=?",
(data.dog_id,),
).fetchone()
if not dog: if not dog:
raise HTTPException(404, "Hund nicht gefunden.") raise HTTPException(404, "Hund nicht gefunden.")
if dog["user_id"] != user["id"] and not dog["is_public"]: if dog["user_id"] != user["id"] and not dog["is_public"]:
raise HTTPException(403, "Dieser Hund ist nicht öffentlich.") raise HTTPException(403, "Dieser Hund ist nicht öffentlich.")
conn.execute("""
conn.execute( INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
"""INSERT INTO hund_des_monats_votes (user_id, dog_id, monat)
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id""", ON CONFLICT(user_id, monat) DO UPDATE SET dog_id=excluded.dog_id
(user["id"], data.dog_id, monat), """, (user["id"], data.dog_id, monat))
)
# Aktuelle Stimmenanzahl für den gewählten Hund
row = conn.execute( row = conn.execute(
"SELECT COUNT(*) as cnt FROM hund_des_monats_votes WHERE dog_id=? AND monat=?", "SELECT COUNT(*) as cnt FROM hund_des_monats_votes WHERE dog_id=? AND monat=?",
(data.dog_id, monat), (data.dog_id, monat),
).fetchone() ).fetchone()
return {"dog_id": data.dog_id, "monat": monat, "stimmen": row["cnt"]} return {"dog_id": data.dog_id, "monat": monat, "stimmen": row["cnt"]}

View file

@ -70,6 +70,7 @@ const App = (() => {
zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true }, zuchthunde: { title: 'Zuchtkartei', module: null, requiresAuth: true },
'zucht-profil': { title: 'Hunde-Profil', module: null }, 'zucht-profil': { title: 'Hunde-Profil', module: null },
gruender: { title: '100 Gründer', module: null }, gruender: { title: '100 Gründer', module: null },
jobs: { title: 'Wir suchen dich', module: null },
}; };
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -18,7 +18,8 @@ window.Page_admin = (() => {
{ id: 'social', label: 'Social Media', icon: 'camera' }, { id: 'social', label: 'Social Media', icon: 'camera' },
{ id: 'analytics', label: 'Analytics', icon: 'target' }, { id: 'analytics', label: 'Analytics', icon: 'target' },
{ id: 'system', label: 'System', icon: 'gear' }, { id: 'system', label: 'System', icon: 'gear' },
{ id: 'jobs', label: 'Jobs', icon: 'clock' }, { id: 'jobs', label: 'Scheduler', icon: 'clock' },
{ id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' },
{ id: 'partner', label: 'Partner', icon: 'handshake' }, { id: 'partner', label: 'Partner', icon: 'handshake' },
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' }, { id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
@ -93,6 +94,7 @@ window.Page_admin = (() => {
case 'partner': await _renderPartner(el); break; case 'partner': await _renderPartner(el); break;
case 'outreach': await _renderOutreach(el); break; case 'outreach': await _renderOutreach(el); break;
case 'audit': await _renderAudit(el); break; case 'audit': await _renderAudit(el); break;
case 'bewerbungen': await _renderBewerbungen(el); break;
} }
} catch (e) { } catch (e) {
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.'); el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
@ -2375,6 +2377,125 @@ window.Page_admin = (() => {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
// ------------------------------------------------------------------
// BEWERBUNGEN — Social-Media-Job
// ------------------------------------------------------------------
async function _renderBewerbungen(el) {
let _statusFilter = 'pending';
async function _load() {
el.innerHTML = `
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4);flex-wrap:wrap;align-items:center">
${['pending','reviewing','accepted','rejected','alle'].map(s => `
<button class="btn btn-sm ${s===_statusFilter?'btn-primary':'btn-ghost'} adm-bew-filter" data-s="${s}">
${s==='pending'?'⏳ Neu':s==='reviewing'?'🔍 In Prüfung':s==='accepted'?'✅ Angenommen':s==='rejected'?'❌ Abgelehnt':'Alle'}
</button>`).join('')}
</div>
<div id="adm-bew-list">${UI.skeleton(3)}</div>`;
el.querySelectorAll('.adm-bew-filter').forEach(btn => {
btn.addEventListener('click', () => {
_statusFilter = btn.dataset.s;
_load();
});
});
try {
const rows = await API.get(`/jobs/admin/applications?status=${_statusFilter}`);
const list = el.querySelector('#adm-bew-list');
if (!rows.length) {
list.innerHTML = _emptyState('user-plus', 'Keine Bewerbungen', 'Noch keine Bewerbungen in diesem Status.');
return;
}
list.innerHTML = rows.map(r => `
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-4)" data-id="${r.id}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-3)">
<div style="flex:1;min-width:0">
<div style="font-weight:700;font-size:var(--text-base)">${_esc(r.name)}
${r.username ? `<span style="color:var(--c-text-muted);font-weight:400;font-size:var(--text-sm)">(@${_esc(r.username)})</span>` : ''}
</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:2px">
${_esc(r.email)} · @${_esc(r.social_handle||'—')}
${r.dog_name ? ` · 🐕 ${_esc(r.dog_name)} (${_esc(r.dog_rasse||'')})` : ''}
</div>
<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:2px">
${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge
</div>
<div style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);
background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3)">
${_esc((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''}
</div>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-2);min-width:120px">
<button class="btn btn-sm btn-primary adm-bew-view" data-id="${r.id}">Details</button>
<select class="form-control adm-bew-status" data-id="${r.id}"
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2)">
<option value="pending" ${r.status==='pending' ?'selected':''}> Neu</option>
<option value="reviewing" ${r.status==='reviewing'?'selected':''}>🔍 Prüfung</option>
<option value="accepted" ${r.status==='accepted' ?'selected':''}> Angenommen</option>
<option value="rejected" ${r.status==='rejected' ?'selected':''}> Abgelehnt</option>
</select>
</div>
</div>
</div>`).join('');
list.querySelectorAll('.adm-bew-status').forEach(sel => {
sel.addEventListener('change', async () => {
const id = sel.dataset.id;
await API.patch(`/jobs/admin/applications/${id}`, { status: sel.value });
UI.toast.success('Status aktualisiert.');
setTimeout(_load, 500);
});
});
list.querySelectorAll('.adm-bew-view').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const app = await API.get(`/jobs/admin/applications/${id}`);
const docsHtml = app.docs?.length
? app.docs.map(d => `<a href="/api/jobs/admin/applications/${id}/docs/${d.id}"
target="_blank" style="display:block;color:var(--c-primary);font-size:var(--text-sm);margin:4px 0">
📎 ${_esc(d.filename)}</a>`).join('')
: '<span style="color:var(--c-text-muted);font-size:var(--text-sm)">Keine Anhänge</span>';
UI.modal.open({
title: `Bewerbung — ${_esc(app.name)}`,
body: `
<div style="display:grid;gap:var(--space-3)">
<div><b>E-Mail:</b> ${_esc(app.email)}</div>
<div><b>Social:</b> @${_esc(app.social_handle||'')}</div>
${app.dog_name ? `<div><b>Hund:</b> ${_esc(app.dog_name)} (${_esc(app.dog_rasse||'')})</div>` : ''}
<div><b>Motivation:</b><br>
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3);
margin-top:var(--space-1);font-size:var(--text-sm);white-space:pre-wrap">${_esc(app.motivation)}</div>
</div>
<div><b>Anhänge:</b><br>${docsHtml}</div>
<div>
<b>Admin-Notiz:</b>
<textarea id="adm-bew-note" class="form-control" rows="2" style="margin-top:var(--space-1)"
placeholder="Interne Notiz / Nachricht an Bewerber">${_esc(app.admin_note||'')}</textarea>
</div>
</div>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="adm-bew-save-note">Notiz speichern</button>`,
});
document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => {
const note = document.getElementById('adm-bew-note')?.value || '';
await API.patch(`/jobs/admin/applications/${id}`, { admin_note: note });
UI.toast.success('Notiz gespeichert.');
UI.modal.close();
});
});
});
} catch (e) {
el.querySelector('#adm-bew-list').innerHTML = _emptyState('warning', 'Fehler', e.message);
}
}
await _load();
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
return { init, refresh, onDogChange }; return { init, refresh, onDogChange };

View file

@ -0,0 +1,260 @@
/* ============================================================
BAN YARO Social-Media-Job Bewerbung
============================================================ */
window.Page_jobs = (() => {
let _container = null;
let _appState = null;
async function init(container, appState) {
_container = container;
_appState = appState;
await _render();
}
async function _render() {
// Bestehende Bewerbung prüfen (nur wenn eingeloggt)
let existingApp = null;
let trialStatus = null;
if (_appState.user) {
try {
const r = await API.get('/jobs/my-application');
existingApp = r.application;
trialStatus = await API.get('/jobs/luna-trial-status');
} catch { /* ignorieren */ }
}
_container.innerHTML = `
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
<!-- Header -->
<div style="text-align:center;margin-bottom:var(--space-6)">
<div style="font-size:48px;margin-bottom:var(--space-3)">🐾</div>
<h1 style="font-size:var(--text-2xl);font-weight:800;margin:0 0 var(--space-2)">
Social-Media-Manager/in gesucht
</h1>
<p style="color:var(--c-text-secondary);margin:0">
Werde das Gesicht von Ban Yaro auf Instagram &amp; TikTok
</p>
</div>
<!-- Stellenbeschreibung -->
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Die Stelle</h2>
<div style="display:grid;gap:var(--space-3)">
${_infoRow('📍', 'Remote', '100 % flexibel — du arbeitest wann und wie du willst')}
${_infoRow('📅', 'Umfang', '12 Posts pro Woche auf Instagram &amp; TikTok')}
${_infoRow('💶', 'Vergütung', '50 € / Monat — wächst mit der Community')}
${_infoRow('🤖', 'Luna an deiner Seite', 'Unser KI-Assistent schreibt Captions, generiert Post-Ideen und Hashtags — du wählst aus und postest')}
${_infoRow('⭐', 'Gründer-Status', 'Du wirst Teil der ersten 100 Gründer — für immer')}
</div>
</div>
</div>
<!-- Luna-Probezugang Teaser -->
<div style="background:linear-gradient(135deg,var(--c-primary),#e8a857);border-radius:var(--radius-lg);
padding:var(--space-5);margin-bottom:var(--space-4);color:#fff">
<div style="font-size:var(--text-lg);font-weight:800;margin-bottom:var(--space-2)">
🤖 Luna 14 Tage kostenlos testen
</div>
<p style="margin:0;opacity:.9;font-size:var(--text-sm)">
Mit deiner Bewerbung schalten wir dir sofort den vollen Zugang zu Luna frei
unserem KI-Assistenten für Social-Media-Posts. Probiere ihn einfach aus,
bevor du dich entscheidest.
</p>
${trialStatus?.active ? `<div style="margin-top:var(--space-3);background:rgba(255,255,255,.2);
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);font-weight:700;font-size:var(--text-sm)">
Dein Probezugang läuft noch ${trialStatus.remaining_days} Tage</div>` : ''}
</div>
<!-- Wen wir suchen -->
<div class="card" style="margin-bottom:var(--space-4)">
<div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Wen wir suchen</h2>
<ul style="margin:0;padding-left:var(--space-5);display:grid;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
<li>Du hast einen Hund und liebst ihn sehr 🐕</li>
<li>Du bist auf Instagram oder TikTok zuhause (nicht professionell, aber aktiv)</li>
<li>Du schreibst gerne und authentisch auf Deutsch</li>
<li>Du hast Lust, eine junge App bekannt zu machen aus Überzeugung</li>
<li>Kein Lebenslauf nötig. Kein Bewerbungs-Anschreiben. Einfach du.</li>
</ul>
</div>
</div>
<!-- Bewerbungsformular oder Status -->
${existingApp ? _renderStatus(existingApp) : _renderForm()}
</div>
`;
if (!existingApp) {
_bindForm();
}
}
function _infoRow(icon, label, text) {
return `
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
<span style="font-size:20px;line-height:1.4">${icon}</span>
<div>
<div style="font-weight:700;font-size:var(--text-sm)">${label}</div>
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${text}</div>
</div>
</div>`;
}
function _renderStatus(app) {
const statusMap = {
pending: { icon: '⏳', text: 'Deine Bewerbung ist eingegangen — wir melden uns bald!', color: 'var(--c-text-secondary)' },
reviewing: { icon: '🔍', text: 'Wir schauen uns deine Bewerbung gerade genauer an.', color: '#0284c7' },
accepted: { icon: '🎉', text: 'Herzlichen Glückwunsch — du bist dabei!', color: 'var(--c-success)' },
rejected: { icon: '😔', text: 'Es hat diesmal leider nicht geklappt.', color: 'var(--c-danger)' },
};
const s = statusMap[app.status] || statusMap.pending;
return `
<div class="card" style="padding:var(--space-5);text-align:center">
<div style="font-size:40px;margin-bottom:var(--space-3)">${s.icon}</div>
<div style="font-weight:700;color:${s.color};font-size:var(--text-lg);margin-bottom:var(--space-2)">${s.text}</div>
<div style="color:var(--c-text-muted);font-size:var(--text-xs)">
Bewerbung eingereicht: ${app.created_at?.slice(0,10)}
</div>
${app.admin_note ? `<div style="margin-top:var(--space-3);background:var(--c-surface-2);
border-radius:var(--radius-md);padding:var(--space-3);font-size:var(--text-sm);
color:var(--c-text-secondary);text-align:left">${UI.esc(app.admin_note)}</div>` : ''}
</div>`;
}
function _renderForm() {
const u = _appState.user;
return `
<div class="card">
<div style="padding:var(--space-5)">
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">
Jetzt bewerben
</h2>
<form id="jobs-form" novalidate>
<div class="form-group">
<label class="form-label">Dein Name *</label>
<input class="form-control" type="text" name="name"
value="${u ? UI.esc(u.name) : ''}" placeholder="Vorname oder Nickname" required>
</div>
<div class="form-group">
<label class="form-label">E-Mail *</label>
<input class="form-control" type="email" name="email"
value="${u ? UI.esc(u.email || '') : ''}" placeholder="deine@email.de" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group" style="margin:0">
<label class="form-label">Hunde-Name</label>
<input class="form-control" type="text" name="dog_name" placeholder="z. B. Bella">
</div>
<div class="form-group" style="margin:0">
<label class="form-label">Rasse</label>
<input class="form-control" type="text" name="dog_rasse" placeholder="z. B. Labrador">
</div>
</div>
<div class="form-group">
<label class="form-label">Instagram oder TikTok Handle *</label>
<div style="position:relative">
<span style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
color:var(--c-text-muted)">@</span>
<input class="form-control" type="text" name="social_handle"
style="padding-left:var(--space-7)" placeholder="dein_handle" required>
</div>
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
Dein öffentliches Profil auf Instagram oder TikTok
</p>
</div>
<div class="form-group">
<label class="form-label">Warum du? *</label>
<textarea class="form-control" name="motivation" rows="5"
placeholder="Erzähl uns kurz wer du bist, was dich an Ban Yaro begeistert und was du dir von der Stelle vorstellst. Kein formeller Ton nötig — schreib einfach wie du sprichst." required
style="resize:vertical"></textarea>
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
Mindestens 80 Zeichen
</p>
</div>
<div class="form-group">
<label class="form-label">Anhänge (optional)</label>
<input class="form-control" type="file" name="files" id="jobs-files"
multiple accept=".pdf,.jpg,.jpeg,.png,.webp,.mp4,.mov"
style="padding:var(--space-2)">
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
Beispiel-Posts, Portfolio, kurzes Video von dir und deinem Hund max. 3 Dateien, je 10 MB.
PDF, Bild oder Video.
</p>
</div>
${!u ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary);
margin-bottom:var(--space-4)">
💡 <b>Tipp:</b> Wenn du dich vorher
<a href="#" id="jobs-login-link" style="color:var(--c-primary)">anmeldest oder registrierst</a>,
bekommst du sofort den 14-tägigen Luna-Probezugang.
</div>` : ''}
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
Bewerbung absenden + Luna freischalten 🚀
</button>
</form>
</div>
</div>`;
}
function _bindForm() {
document.getElementById('jobs-login-link')?.addEventListener('click', e => {
e.preventDefault();
if (window.App) App.navigate('settings');
});
document.getElementById('jobs-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const fd = new FormData(e.target);
// Dateien aus file-input übernehmen
const fileInput = document.getElementById('jobs-files');
if (fileInput?.files?.length) {
fd.delete('files');
for (const f of fileInput.files) fd.append('files', f);
}
await UI.asyncButton(btn, async () => {
const resp = await fetch('/api/jobs/apply', {
method: 'POST',
body: fd,
headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` },
credentials: 'include',
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Fehler beim Absenden.');
}
const result = await resp.json();
if (result.luna_trial) {
UI.toast.success('🎉 Bewerbung eingegangen! Dein Luna-Probezugang ist jetzt aktiv.');
// User-State aktualisieren damit Luna sofort zugänglich ist
if (_appState.user && window.API) {
try { _appState.user = await API.auth.me(); } catch { /* ignore */ }
}
} else {
UI.toast.success('Bewerbung eingegangen! Wir melden uns bald.');
}
await _render();
});
});
}
return { init };
})();

View file

@ -13,6 +13,8 @@ window.Page_movies = (() => {
let _filme = []; let _filme = [];
let _activeTab = 'filme'; let _activeTab = 'filme';
let _filter = 'alle'; let _filter = 'alle';
let _typ = 'alle'; // alle | film | serie | doku
let _sort = 'default'; // default | titel | jahr_desc | jahr_asc | imdb | bewertung
// ---------------------------------------------------------- // ----------------------------------------------------------
// INIT // INIT
@ -70,20 +72,44 @@ window.Page_movies = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// TAB 1: FILME // TAB 1: FILME
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _loadFilme() {
_filme = await API.get(`/movies/filme?sort=${_sort}&typ=${_typ}`);
}
async function _renderFilme(content) { async function _renderFilme(content) {
try { try {
_filme = await API.get('/movies/filme'); await _loadFilme();
} catch { } catch {
content.innerHTML = UI.emptyState({ icon: 'film-slate', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' }); content.innerHTML = UI.emptyState({ icon: 'film-slate', title: 'Filme konnten nicht geladen werden', text: 'Bitte versuche es erneut.' });
return; return;
} }
content.innerHTML = ` content.innerHTML = `
<div class="movies-controls">
<div class="movies-filter-row"> <div class="movies-filter-row">
<button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button> <button class="movies-filter-btn${_filter === 'alle' ? ' movies-filter-btn--active' : ''}" data-filter="alle">Alle</button>
<button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</button> <button class="movies-filter-btn${_filter === 'stirbt' ? ' movies-filter-btn--active' : ''}" data-filter="stirbt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</button>
<button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> Hund überlebt</button> <button class="movies-filter-btn${_filter === 'ueberlebt' ? ' movies-filter-btn--active' : ''}" data-filter="ueberlebt"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> Hund überlebt</button>
<button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg>+</button> <button class="movies-filter-btn${_filter === 'top' ? ' movies-filter-btn--active' : ''}" data-filter="top"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#star"></use></svg> Top</button>
</div>
<div class="movies-filter-row" style="margin-top:var(--space-2)">
<button class="movies-type-btn${_typ === 'alle' ? ' movies-filter-btn--active' : ''}" data-typ="alle">Alle Typen</button>
<button class="movies-type-btn${_typ === 'film' ? ' movies-filter-btn--active' : ''}" data-typ="film">🎬 Filme</button>
<button class="movies-type-btn${_typ === 'serie' ? ' movies-filter-btn--active' : ''}" data-typ="serie">📺 Serien</button>
<button class="movies-type-btn${_typ === 'doku' ? ' movies-filter-btn--active' : ''}" data-typ="doku">🎥 Dokus</button>
</div>
<div style="display:flex;align-items:center;gap:var(--space-2);margin-top:var(--space-2)">
<svg class="ph-icon" aria-hidden="true" style="width:16px;height:16px;color:var(--c-text-muted)"><use href="/icons/phosphor.svg#sort-ascending"></use></svg>
<select id="movies-sort" class="form-control" style="flex:1;font-size:var(--text-sm);padding:var(--space-2) var(--space-3)">
<option value="default" ${_sort==='default' ?'selected':''}>Empfohlen</option>
<option value="bewertung" ${_sort==='bewertung' ?'selected':''}>Community-Bewertung</option>
<option value="imdb" ${_sort==='imdb' ?'selected':''}>IMDb-Bewertung</option>
<option value="jahr_desc" ${_sort==='jahr_desc' ?'selected':''}>Neueste zuerst</option>
<option value="jahr_asc" ${_sort==='jahr_asc' ?'selected':''}>Älteste zuerst</option>
<option value="titel" ${_sort==='titel' ?'selected':''}>Titel AZ</option>
</select>
<span id="movies-count" style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap"></span>
</div>
</div> </div>
<div class="movie-grid" id="movie-grid"></div> <div class="movie-grid" id="movie-grid"></div>
`; `;
@ -97,6 +123,26 @@ window.Page_movies = (() => {
}); });
}); });
content.querySelectorAll('.movies-type-btn').forEach(btn => {
btn.addEventListener('click', async () => {
_typ = btn.dataset.typ;
content.querySelectorAll('.movies-type-btn').forEach(b => b.classList.remove('movies-filter-btn--active'));
btn.classList.add('movies-filter-btn--active');
const grid = content.querySelector('#movie-grid');
grid.innerHTML = UI.skeleton(3);
await _loadFilme();
_renderMovieGrid(grid);
});
});
content.querySelector('#movies-sort')?.addEventListener('change', async e => {
_sort = e.target.value;
const grid = content.querySelector('#movie-grid');
grid.innerHTML = UI.skeleton(3);
await _loadFilme();
_renderMovieGrid(grid);
});
_renderMovieGrid(content.querySelector('#movie-grid')); _renderMovieGrid(content.querySelector('#movie-grid'));
} }
@ -106,7 +152,10 @@ window.Page_movies = (() => {
if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund); if (_filter === 'stirbt') list = list.filter(f => f.stirbt_der_hund);
if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund); if (_filter === 'ueberlebt') list = list.filter(f => !f.stirbt_der_hund);
if (_filter === 'top') list = list.filter(f => f.bewertung_avg >= 4.0); if (_filter === 'top') list = list.filter(f => (f.imdb_rating || 0) >= 7.5 || f.bewertung_avg >= 4.0);
const countEl = document.getElementById('movies-count');
if (countEl) countEl.textContent = `${list.length} Einträge`;
if (list.length === 0) { if (list.length === 0) {
grid.innerHTML = `<div style="grid-column:1/-1;padding:var(--space-8);text-align:center;color:var(--c-text-secondary)">Keine Filme für diesen Filter.</div>`; grid.innerHTML = `<div style="grid-column:1/-1;padding:var(--space-8);text-align:center;color:var(--c-text-secondary)">Keine Filme für diesen Filter.</div>`;
@ -130,18 +179,24 @@ window.Page_movies = (() => {
function _movieCard(film) { function _movieCard(film) {
const stirbt = film.stirbt_der_hund; const stirbt = film.stirbt_der_hund;
const tag = stirbt const tag = stirbt
? `<div class="movie-tag-stirbt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> ACHTUNG: Der Hund stirbt</div>` ? `<div class="movie-tag-stirbt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Hund stirbt</div>`
: `<div class="movie-tag-ueberlebt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Der Hund überlebt</div>`; : `<div class="movie-tag-ueberlebt"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#check-circle"></use></svg> Hund überlebt</div>`;
const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false); const stars = _starsHtml(film.bewertung_avg, film.id, film.user_rating, false);
const typLabel = film.typ === 'serie' ? '📺 Serie' : film.typ === 'doku' ? '🎥 Doku' : '';
const imdb = film.imdb_rating ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">IMDb ${film.imdb_rating}</span>` : '';
const streaming = film.streaming ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${_esc(film.streaming)}</span>` : '';
return ` return `
<div class="movie-card" data-film-id="${_esc(film.id)}"> <div class="movie-card" data-film-id="${_esc(film.id)}">
<div class="movie-card-emoji">${film.bild_emoji}</div> <div class="movie-card-emoji">${film.bild_emoji}</div>
<div class="movie-card-body"> <div class="movie-card-body">
<div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div> <div class="movie-card-title">${_esc(film.titel)} <span class="movie-card-year">(${film.jahr})</span></div>
<div class="movie-card-genre">${_esc(film.genre)}</div> <div class="movie-card-genre" style="display:flex;gap:var(--space-2);align-items:center;flex-wrap:wrap">
<span>${_esc(film.genre)}</span>${typLabel ? `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${typLabel}</span>` : ''}
</div>
<div class="movie-card-rasse"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${_esc(film.hund_rasse)}</div> <div class="movie-card-rasse"><svg class="ph-icon" aria-hidden="true" style="width:14px;height:14px"><use href="/icons/phosphor.svg#paw-print"></use></svg> ${_esc(film.hund_rasse)}</div>
${tag} ${tag}
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-1)">${imdb}${streaming}</div>
<div class="movie-card-stars">${stars}</div> <div class="movie-card-stars">${stars}</div>
</div> </div>
</div> </div>