diff --git a/.gitignore b/.gitignore
index e51a866..696ee7a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,6 @@ __pycache__/
*.db
*.db-wal
*.db-shm
+
+# Design-Quell-Dateien (nicht für Server)
+/icons/
diff --git a/Makefile b/Makefile
index ea0c151..6eb84e9 100644
--- a/Makefile
+++ b/Makefile
@@ -8,8 +8,8 @@ DS_HOST := ds
DS_IP := 10.47.11.10
# Hinweis: NPM braucht 10.47.11.99 als Forward-IP (Macvlan-Shim), nicht .10
DS_SSH_PORT := 22
-DS_PATH := /volume1/docker/ban-yaro
-CONTAINER := ban-yaro # container_name (für docker logs/exec)
+DS_PATH := /volume1/docker/banyaro
+CONTAINER := banyaro # container_name (für docker logs/exec)
SERVICE := banyaro # service-name in docker-compose.yml (für docker compose restart)
GIT_REMOTE := origin
DOCKER := sudo /usr/local/bin/docker
diff --git a/backend/database.py b/backend/database.py
index 209239f..d268de4 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -88,6 +88,14 @@ def init_db():
);
CREATE INDEX IF NOT EXISTS idx_diary_dog ON diary(dog_id, datum DESC);
+ -- TAGEBUCH ↔ HUNDE (n:m — ein Eintrag kann mehrere Hunde betreffen)
+ CREATE TABLE IF NOT EXISTS diary_dogs (
+ diary_id INTEGER NOT NULL REFERENCES diary(id) ON DELETE CASCADE,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ PRIMARY KEY (diary_id, dog_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_diary_dogs_dog ON diary_dogs(dog_id);
+
-- GESUNDHEIT
CREATE TABLE IF NOT EXISTS health (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -186,6 +194,15 @@ def init_db():
PRIMARY KEY (walk_id, user_id)
);
+ -- GASSI-TREFFEN ↔ HUNDE (n:m — Teilnehmer kann mehrere Hunde mitbringen)
+ CREATE TABLE IF NOT EXISTS walk_participant_dogs (
+ walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
+ PRIMARY KEY (walk_id, user_id, dog_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_wpd_dog ON walk_participant_dogs(dog_id);
+
-- FORUM
CREATE TABLE IF NOT EXISTS forum_threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -234,4 +251,48 @@ def init_db():
""")
+ # Migrations: neue Spalten zu bestehenden Tabellen hinzufügen (idempotent)
+ _migrate(conn_factory=db)
+
logger.info("Datenbank initialisiert.")
+
+
+def _migrate(conn_factory):
+ """Fügt fehlende Spalten hinzu und führt Datenmigration durch (idempotent)."""
+ migrations = [
+ # Giftköder: Auflösungs-Details für spätere KI-Analyse
+ ("poison", "geloest_von", "INTEGER"),
+ ("poison", "geloest_at", "TEXT"),
+ ("poison", "geloest_grund", "TEXT"),
+ # Gesundheit: erweiterte Felder je nach Eintragstyp
+ ("health", "charge_nr", "TEXT"),
+ ("health", "tierarzt_name", "TEXT"),
+ ("health", "kosten", "REAL"),
+ ("health", "diagnose", "TEXT"),
+ ("health", "dosierung", "TEXT"),
+ ("health", "haeufigkeit", "TEXT"),
+ ("health", "aktiv", "INTEGER NOT NULL DEFAULT 1"),
+ ("health", "bis_datum", "TEXT"),
+ ("health", "schweregrad", "TEXT"),
+ ("health", "reaktion", "TEXT"),
+ ("health", "datei_url", "TEXT"),
+ ("health", "datei_typ", "TEXT"),
+ ]
+ with conn_factory() as conn:
+ for table, column, col_type in migrations:
+ existing = [
+ row[1] for row in
+ conn.execute(f"PRAGMA table_info({table})").fetchall()
+ ]
+ if column not in existing:
+ conn.execute(
+ f"ALTER TABLE {table} ADD COLUMN {column} {col_type}"
+ )
+ logger.info(f"Migration: {table}.{column} hinzugefügt.")
+
+ # Datenmigration: diary_dogs für bestehende Einträge befüllen
+ conn.execute("""
+ INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id)
+ SELECT id, dog_id FROM diary
+ """)
+ logger.info("Migration: diary_dogs Backfill abgeschlossen.")
diff --git a/backend/ki.py b/backend/ki.py
index 6eb0f1f..c4f6ec2 100644
--- a/backend/ki.py
+++ b/backend/ki.py
@@ -289,6 +289,71 @@ Bewerte als JSON:
return {"plausibel": True, "typ": "unbekannt", "hinweis": description}
+async def health_summary(health_data: list, dog_info: dict,
+ user_is_premium: bool = False) -> str:
+ """
+ Tierärztliche Zusammenfassung aller Gesundheitsdaten — lokal für alle.
+ Fasst Impfungen, Besuche, Medikamente etc. in einem lesbaren Bericht zusammen.
+ """
+ system = (
+ "Du bist ein erfahrener Veterinär-Assistent. "
+ "Erstelle präzise, strukturierte Gesundheitsberichte für Hunde auf Deutsch. "
+ "Weise auf fällige Impfungen und wichtige Termine hin."
+ )
+
+ # Daten nach Typ gruppieren für den Prompt
+ def _fmt(entries, typ):
+ subset = [e for e in entries if e.get("typ") == typ]
+ if not subset:
+ return " (keine Einträge)"
+ lines = []
+ for e in subset[:10]: # maximal 10 pro Typ
+ line = f" - {e.get('datum', '?')}: {e.get('bezeichnung', '?')}"
+ if e.get("naechstes"):
+ line += f" (nächste Fälligkeit: {e['naechstes']})"
+ if e.get("notiz"):
+ line += f" — {e['notiz']}"
+ lines.append(line)
+ return "\n".join(lines)
+
+ heute = __import__("datetime").date.today().isoformat()
+ prompt = f"""
+Hund: {dog_info.get('name')}, {dog_info.get('rasse', 'unbekannt')},
+Geburtstag: {dog_info.get('geburtstag', 'unbekannt')}, Gewicht: {dog_info.get('gewicht_kg', '?')} kg
+
+Heutiges Datum: {heute}
+
+=== IMPFUNGEN ===
+{_fmt(health_data, 'impfung')}
+
+=== ENTWURMUNG ===
+{_fmt(health_data, 'entwurmung')}
+
+=== TIERARZTBESUCHE ===
+{_fmt(health_data, 'tierarzt')}
+
+=== MEDIKAMENTE ===
+{_fmt(health_data, 'medikament')}
+
+=== ALLERGIEN ===
+{_fmt(health_data, 'allergie')}
+
+Erstelle einen strukturierten Gesundheitsbericht mit:
+1. Aktueller Status (2-3 Sätze)
+2. Fällige / überfällige Impfungen und Termine
+3. Wichtige Hinweise für den nächsten Tierarztbesuch
+4. Allgemeine Empfehlungen
+
+Schreibe klar und verständlich für den Hundebesitzer.
+"""
+ return await complete(
+ prompt, system,
+ max_tokens=700,
+ requires_premium=False, # Kostenlos für alle
+ user_is_premium=user_is_premium,
+ )
+
+
# ------------------------------------------------------------------
# Status-Endpoint (für Admin/Debug)
# ------------------------------------------------------------------
diff --git a/backend/main.py b/backend/main.py
index 96d0236..88c7e1e 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -89,6 +89,10 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
+@app.get("/favicon.ico")
+async def favicon():
+ return FileResponse(f"{STATIC_DIR}/icons/favicon.ico")
+
@app.get("/manifest.json")
async def manifest():
return FileResponse(f"{STATIC_DIR}/manifest.json")
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 904fb5d..29e57bc 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,4 +1,6 @@
fastapi==0.115.0
+Pillow==11.2.1
+pillow-heif==0.22.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9
pydantic[email]==2.8.2
diff --git a/backend/routes/diary.py b/backend/routes/diary.py
index 56f348d..ba59f6d 100644
--- a/backend/routes/diary.py
+++ b/backend/routes/diary.py
@@ -13,20 +13,23 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DiaryCreate(BaseModel):
- datum: Optional[str] = None # ISO date, default heute
- typ: str = "eintrag"
- titel: Optional[str] = None
- text: Optional[str] = None
- tags: Optional[list] = None
- gps_lat: Optional[float] = None
- gps_lon: Optional[float] = None
- is_milestone: bool = False
+ datum: Optional[str] = None # ISO date, default heute
+ typ: str = "eintrag"
+ titel: Optional[str] = None
+ text: Optional[str] = None
+ tags: Optional[list] = None
+ gps_lat: Optional[float] = None
+ gps_lon: Optional[float] = None
+ is_milestone: bool = False
+ dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
+
class DiaryUpdate(BaseModel):
- titel: Optional[str] = None
- text: Optional[str] = None
- tags: Optional[list] = None
- is_milestone: Optional[bool] = None
+ titel: Optional[str] = None
+ text: Optional[str] = None
+ tags: Optional[list] = None
+ is_milestone: Optional[bool] = None
+ dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen
def _own_dog(dog_id: int, user_id: int, conn):
@@ -38,23 +41,63 @@ def _own_dog(dog_id: int, user_id: int, conn):
return dog
+def _validate_dog_ids(dog_ids: list[int], primary: int, user_id: int, conn) -> list[int]:
+ """Stellt sicher dass alle IDs dem User gehören. Gibt die bereinigte Liste zurück."""
+ all_ids = list({primary} | set(dog_ids))
+ for did in all_ids:
+ _own_dog(did, user_id, conn)
+ return all_ids
+
+
+def _fetch_dog_ids(conn, entry_ids: list[int]) -> dict:
+ """Gibt {entry_id: [dog_id, ...]} zurück."""
+ if not entry_ids:
+ return {}
+ ph = ",".join("?" * len(entry_ids))
+ rows = conn.execute(
+ f"SELECT diary_id, dog_id FROM diary_dogs WHERE diary_id IN ({ph})",
+ entry_ids
+ ).fetchall()
+ result = {}
+ for r in rows:
+ result.setdefault(r["diary_id"], []).append(r["dog_id"])
+ return result
+
+
+def _set_dog_ids(conn, entry_id: int, dog_ids: list[int]):
+ conn.execute("DELETE FROM diary_dogs WHERE diary_id=?", (entry_id,))
+ for did in dog_ids:
+ conn.execute(
+ "INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)",
+ (entry_id, did)
+ )
+
+
+def _entry_dict(row, dog_ids_map: dict) -> dict:
+ e = dict(row)
+ e["tags"] = json.loads(e["tags"]) if e["tags"] else []
+ e["dog_ids"] = dog_ids_map.get(e["id"], [e["dog_id"]])
+ return e
+
+
@router.get("/{dog_id}/diary")
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
user=Depends(get_current_user)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
+ # Einträge des primären Hundes SOWIE Einträge wo der Hund als weiterer zugeordnet ist
rows = conn.execute(
- """SELECT * FROM diary WHERE dog_id=?
- ORDER BY datum DESC, created_at DESC
+ """SELECT DISTINCT d.* FROM diary d
+ LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
+ WHERE d.dog_id = ? OR dd.dog_id = ?
+ ORDER BY d.datum DESC, d.created_at DESC
LIMIT ? OFFSET ?""",
- (dog_id, limit, offset)
+ (dog_id, dog_id, limit, offset)
).fetchall()
- entries = []
- for r in rows:
- e = dict(r)
- e["tags"] = json.loads(e["tags"]) if e["tags"] else []
- entries.append(e)
- return entries
+ ids = [r["id"] for r in rows]
+ dogs_map = _fetch_dog_ids(conn, ids)
+
+ return [_entry_dict(r, dogs_map) for r in rows]
@router.post("/{dog_id}/diary", status_code=201)
@@ -72,6 +115,8 @@ async def create_diary(dog_id: int, data: DiaryCreate,
with db() as conn:
_own_dog(dog_id, user["id"], conn)
+ all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn)
+
conn.execute(
"""INSERT INTO diary
(dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone)
@@ -85,10 +130,10 @@ async def create_diary(dog_id: int, data: DiaryCreate,
"SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1",
(dog_id,)
).fetchone()
+ _set_dog_ids(conn, entry["id"], all_dogs)
+ dogs_map = _fetch_dog_ids(conn, [entry["id"]])
- e = dict(entry)
- e["tags"] = json.loads(e["tags"]) if e["tags"] else []
- return e
+ return _entry_dict(entry, dogs_map)
@router.get("/{dog_id}/diary/{entry_id}")
@@ -96,13 +141,16 @@ async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
row = conn.execute(
- "SELECT * FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
+ """SELECT DISTINCT d.* FROM diary d
+ LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
+ WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""",
+ (entry_id, dog_id, dog_id)
).fetchone()
- if not row:
- raise HTTPException(404, "Eintrag nicht gefunden.")
- e = dict(row)
- e["tags"] = json.loads(e["tags"]) if e["tags"] else []
- return e
+ if not row:
+ raise HTTPException(404, "Eintrag nicht gefunden.")
+ dogs_map = _fetch_dog_ids(conn, [entry_id])
+
+ return _entry_dict(row, dogs_map)
@router.patch("/{dog_id}/diary/{entry_id}")
@@ -110,20 +158,43 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
user=Depends(get_current_user)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
- fields = {k: v for k, v in data.model_dump().items() if v is not None}
+
+ # Prüfen ob Eintrag diesem Hund gehört (direkt oder via diary_dogs)
+ exists = conn.execute(
+ """SELECT 1 FROM diary d
+ LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
+ WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""",
+ (entry_id, dog_id, dog_id)
+ ).fetchone()
+ if not exists:
+ raise HTTPException(404, "Eintrag nicht gefunden.")
+
+ # Felder updaten
+ fields = {k: v for k, v in data.model_dump(exclude={"dog_ids"}).items()
+ if v is not None}
if "tags" in fields:
fields["tags"] = json.dumps(fields["tags"])
- if not fields:
- raise HTTPException(400, "Keine Änderungen.")
- set_clause = ", ".join(f"{k}=?" for k in fields)
- conn.execute(
- f"UPDATE diary SET {set_clause} WHERE id=? AND dog_id=?",
- list(fields.values()) + [entry_id, dog_id]
- )
- row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
- e = dict(row)
- e["tags"] = json.loads(e["tags"]) if e["tags"] else []
- return e
+ if fields:
+ # primary dog_id für SET-Clause ermitteln (der Eintrag bleibt dem Erstell-Hund)
+ set_clause = ", ".join(f"{k}=?" for k in fields)
+ conn.execute(
+ f"UPDATE diary SET {set_clause} WHERE id=?",
+ list(fields.values()) + [entry_id]
+ )
+
+ # Hunde-Zuweisung aktualisieren
+ if data.dog_ids is not None:
+ # primary dog des Eintrags ermitteln
+ primary = conn.execute(
+ "SELECT dog_id FROM diary WHERE id=?", (entry_id,)
+ ).fetchone()["dog_id"]
+ all_dogs = _validate_dog_ids(data.dog_ids, primary, user["id"], conn)
+ _set_dog_ids(conn, entry_id, all_dogs)
+
+ row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
+ dogs_map = _fetch_dog_ids(conn, [entry_id])
+
+ return _entry_dict(row, dogs_map)
@router.delete("/{dog_id}/diary/{entry_id}", status_code=204)
@@ -142,7 +213,10 @@ async def upload_media(dog_id: int, entry_id: int,
with db() as conn:
_own_dog(dog_id, user["id"], conn)
entry = conn.execute(
- "SELECT id FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
+ """SELECT d.id FROM diary d
+ LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
+ WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""",
+ (entry_id, dog_id, dog_id)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py
index ca63968..e59b01d 100644
--- a/backend/routes/dogs.py
+++ b/backend/routes/dogs.py
@@ -113,13 +113,28 @@ async def upload_photo(
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
- # Datei speichern
- ext = os.path.splitext(file.filename or "")[1] or ".jpg"
- filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}{ext}"
+ # Datei immer als JPEG speichern (HEIC/PNG/WebP → kompatibel für alle Browser)
+ import io
+ from PIL import Image
+ try:
+ import pillow_heif
+ pillow_heif.register_heif_opener()
+ except ImportError:
+ pass
+
+ content = await file.read()
+ try:
+ img = Image.open(io.BytesIO(content)).convert("RGB")
+ buf = io.BytesIO()
+ img.save(buf, format="JPEG", quality=90)
+ content = buf.getvalue()
+ except Exception:
+ pass # Fallback: Originaldaten speichern
+
+ filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}.jpg"
path = os.path.join(MEDIA_DIR, "dogs", filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
- content = await file.read()
with open(path, "wb") as f:
f.write(content)
diff --git a/backend/routes/health.py b/backend/routes/health.py
index db2c27d..8911d99 100644
--- a/backend/routes/health.py
+++ b/backend/routes/health.py
@@ -1,3 +1,283 @@
-"""BAN YARO — health Routes (Stub, wird ausgebaut)"""
-from fastapi import APIRouter
-router = APIRouter()
+"""BAN YARO — Gesundheit & Impfpass Routes"""
+
+import os, uuid
+from datetime import date, datetime
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
+from pydantic import BaseModel
+from typing import Optional
+from database import db
+from auth import get_current_user
+
+router = APIRouter()
+MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
+
+# Erlaubte Typen
+TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument"}
+
+
+# ------------------------------------------------------------------
+# Schemas
+# ------------------------------------------------------------------
+class HealthCreate(BaseModel):
+ typ: str
+ bezeichnung: str
+ datum: str
+ naechstes: Optional[str] = None
+ notiz: Optional[str] = None
+ # Gewicht
+ wert: Optional[float] = None
+ einheit: Optional[str] = "kg"
+ # Impfung
+ charge_nr: Optional[str] = None
+ tierarzt_name: Optional[str] = None
+ # Tierarztbesuch
+ kosten: Optional[float] = None
+ diagnose: Optional[str] = None
+ # Medikament
+ dosierung: Optional[str] = None
+ haeufigkeit: Optional[str] = None
+ aktiv: Optional[int] = 1
+ bis_datum: Optional[str] = None
+ # Allergie
+ schweregrad: Optional[str] = None # leicht | mittel | schwer
+ reaktion: Optional[str] = None
+ erinnerung: Optional[int] = 1
+
+
+class HealthUpdate(BaseModel):
+ bezeichnung: Optional[str] = None
+ datum: Optional[str] = None
+ naechstes: Optional[str] = None
+ notiz: Optional[str] = None
+ wert: Optional[float] = None
+ einheit: Optional[str] = None
+ charge_nr: Optional[str] = None
+ tierarzt_name: Optional[str] = None
+ kosten: Optional[float] = None
+ diagnose: Optional[str] = None
+ dosierung: Optional[str] = None
+ haeufigkeit: Optional[str] = None
+ aktiv: Optional[int] = None
+ bis_datum: Optional[str] = None
+ schweregrad: Optional[str] = None
+ reaktion: Optional[str] = None
+ erinnerung: Optional[int] = None
+
+
+# ------------------------------------------------------------------
+# Hilfsfunktion: Zugriffscheck Dog → User
+# ------------------------------------------------------------------
+def _check_dog_owner(conn, dog_id: int, user_id: int):
+ dog = conn.execute(
+ "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+ return dog
+
+
+# ------------------------------------------------------------------
+# GET /api/dogs/{dog_id}/health
+# ------------------------------------------------------------------
+@router.get("/{dog_id}/health")
+async def list_health(dog_id: int, typ: Optional[str] = None,
+ user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_owner(conn, dog_id, user["id"])
+ if typ:
+ rows = conn.execute(
+ "SELECT * FROM health WHERE dog_id=? AND typ=? ORDER BY datum DESC",
+ (dog_id, typ)
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ "SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC",
+ (dog_id,)
+ ).fetchall()
+ return [dict(r) for r in rows]
+
+
+# ------------------------------------------------------------------
+# POST /api/dogs/{dog_id}/health
+# ------------------------------------------------------------------
+@router.post("/{dog_id}/health", status_code=201)
+async def create_health(dog_id: int, data: HealthCreate,
+ user=Depends(get_current_user)):
+ if data.typ not in TYPEN:
+ raise HTTPException(400, f"Unbekannter Typ. Erlaubt: {', '.join(TYPEN)}")
+
+ with db() as conn:
+ _check_dog_owner(conn, dog_id, user["id"])
+ conn.execute(
+ """INSERT INTO health
+ (dog_id, typ, bezeichnung, datum, naechstes, notiz,
+ wert, einheit, charge_nr, tierarzt_name, kosten, diagnose,
+ dosierung, haeufigkeit, aktiv, bis_datum,
+ schweregrad, reaktion, erinnerung)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
+ (dog_id, data.typ, data.bezeichnung, data.datum, data.naechstes,
+ data.notiz, data.wert, data.einheit, data.charge_nr,
+ data.tierarzt_name, data.kosten, data.diagnose, data.dosierung,
+ data.haeufigkeit, data.aktiv, data.bis_datum,
+ data.schweregrad, data.reaktion, data.erinnerung)
+ )
+ row = conn.execute(
+ "SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1",
+ (dog_id,)
+ ).fetchone()
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# PATCH /api/dogs/{dog_id}/health/{id}
+# ------------------------------------------------------------------
+@router.patch("/{dog_id}/health/{entry_id}")
+async def update_health(dog_id: int, entry_id: int, data: HealthUpdate,
+ user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_owner(conn, dog_id, user["id"])
+ entry = conn.execute(
+ "SELECT * FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
+ ).fetchone()
+ if not entry:
+ raise HTTPException(404, "Eintrag nicht gefunden.")
+
+ updates = {k: v for k, v in data.model_dump().items() if v is not None}
+ if not updates:
+ return dict(entry)
+
+ set_clause = ", ".join(f"{k}=?" for k in updates)
+ values = list(updates.values()) + [entry_id]
+ conn.execute(f"UPDATE health SET {set_clause} WHERE id=?", values)
+ row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone()
+ return dict(row)
+
+
+# ------------------------------------------------------------------
+# DELETE /api/dogs/{dog_id}/health/{id}
+# ------------------------------------------------------------------
+@router.delete("/{dog_id}/health/{entry_id}", status_code=204)
+async def delete_health(dog_id: int, entry_id: int, user=Depends(get_current_user)):
+ with db() as conn:
+ _check_dog_owner(conn, dog_id, user["id"])
+ entry = conn.execute(
+ "SELECT id FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
+ ).fetchone()
+ if not entry:
+ raise HTTPException(404, "Eintrag nicht gefunden.")
+ conn.execute("DELETE FROM health WHERE id=?", (entry_id,))
+ return None
+
+
+# ------------------------------------------------------------------
+# POST /api/dogs/{dog_id}/health/{id}/dokument — Datei-Upload
+# ------------------------------------------------------------------
+@router.post("/{dog_id}/health/{entry_id}/dokument")
+async def upload_dokument(
+ dog_id: int,
+ entry_id: int,
+ file: UploadFile = File(...),
+ user=Depends(get_current_user),
+):
+ with db() as conn:
+ _check_dog_owner(conn, dog_id, user["id"])
+ entry = conn.execute(
+ "SELECT id FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
+ ).fetchone()
+ if not entry:
+ raise HTTPException(404, "Eintrag nicht gefunden.")
+
+ ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg"
+ if ext not in {".jpg", ".jpeg", ".png", ".pdf", ".webp"}:
+ raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.")
+
+ filename = f"health_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
+ path = os.path.join(MEDIA_DIR, "health", filename)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+
+ with open(path, "wb") as f:
+ f.write(await file.read())
+
+ datei_url = f"/media/health/{filename}"
+ datei_typ = "pdf" if ext == ".pdf" else "image"
+
+ with db() as conn:
+ conn.execute(
+ "UPDATE health SET datei_url=?, datei_typ=? WHERE id=?",
+ (datei_url, datei_typ, entry_id)
+ )
+
+ return {"datei_url": datei_url, "datei_typ": datei_typ}
+
+
+# ------------------------------------------------------------------
+# POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung
+# ------------------------------------------------------------------
+class SymptomCheckRequest(BaseModel):
+ symptoms: str
+
+
+@router.post("/{dog_id}/health/symptom-check")
+async def symptom_check(dog_id: int, data: SymptomCheckRequest,
+ user=Depends(get_current_user)):
+ from ki import symptom_check as ki_symptom_check, KIUnavailableError
+
+ with db() as conn:
+ dog = conn.execute(
+ "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+ dog_info = dict(dog)
+ if dog_info.get("geburtstag"):
+ try:
+ from datetime import date
+ geb = date.fromisoformat(dog_info["geburtstag"])
+ dog_info["alter_jahre"] = round((date.today() - geb).days / 365.25, 1)
+ except Exception:
+ dog_info["alter_jahre"] = "unbekannt"
+
+ try:
+ result = await ki_symptom_check(
+ symptoms=data.symptoms,
+ dog_info=dog_info,
+ user_is_premium=bool(user.get("is_premium")),
+ )
+ return result
+ except KIUnavailableError as e:
+ raise HTTPException(503, str(e))
+
+
+# ------------------------------------------------------------------
+# POST /api/dogs/{dog_id}/health/ki-zusammenfassung
+# ------------------------------------------------------------------
+@router.post("/{dog_id}/health/ki-zusammenfassung")
+async def ki_zusammenfassung(dog_id: int, user=Depends(get_current_user)):
+ from ki import health_summary, KIUnavailableError, KIPremiumRequired
+
+ with db() as conn:
+ dog = conn.execute(
+ "SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
+ ).fetchone()
+ if not dog:
+ raise HTTPException(404, "Hund nicht gefunden.")
+
+ rows = conn.execute(
+ "SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC",
+ (dog_id,)
+ ).fetchall()
+
+ health_data = [dict(r) for r in rows]
+
+ try:
+ result = await health_summary(
+ health_data=health_data,
+ dog_info=dict(dog),
+ user_is_premium=bool(user.get("is_premium")),
+ )
+ return {"zusammenfassung": result}
+ except KIPremiumRequired as e:
+ raise HTTPException(402, str(e))
+ except KIUnavailableError as e:
+ raise HTTPException(503, str(e))
diff --git a/backend/routes/poison.py b/backend/routes/poison.py
index 4916a6f..9459c61 100644
--- a/backend/routes/poison.py
+++ b/backend/routes/poison.py
@@ -35,6 +35,10 @@ class PoisonCreate(BaseModel):
typ: str = "unbekannt"
+class PoisonResolve(BaseModel):
+ grund: str = "beseitigt" # beseitigt | fehlerhaft | anderes
+
+
# ------------------------------------------------------------------
# GET /api/poison — aktive Meldungen im Umkreis (kein Login nötig)
# ------------------------------------------------------------------
@@ -114,7 +118,11 @@ async def confirm_poison(poison_id: int, user=Depends(get_current_user)):
# Nur der Melder selbst oder ein Admin
# ------------------------------------------------------------------
@router.post("/{poison_id}/resolve")
-async def resolve_poison(poison_id: int, user=Depends(get_current_user)):
+async def resolve_poison(
+ poison_id: int,
+ data: PoisonResolve = PoisonResolve(),
+ user=Depends(get_current_user)
+):
with db() as conn:
entry = conn.execute(
"SELECT * FROM poison WHERE id=?", (poison_id,)
@@ -124,7 +132,16 @@ async def resolve_poison(poison_id: int, user=Depends(get_current_user)):
e = dict(entry)
if e["user_id"] != user["id"] and user.get("rolle") != "admin":
raise HTTPException(403, "Keine Berechtigung.")
- conn.execute("UPDATE poison SET geloest=1 WHERE id=?", (poison_id,))
+ # Soft-Delete: Eintrag bleibt für spätere KI-Musteranalyse erhalten
+ conn.execute(
+ """UPDATE poison
+ SET geloest=1,
+ geloest_von=?,
+ geloest_at=datetime('now'),
+ geloest_grund=?
+ WHERE id=?""",
+ (user["id"], data.grund, poison_id)
+ )
return {"ok": True}
diff --git a/backend/static/css/components.css b/backend/static/css/components.css
index afdcb80..750fc4a 100644
--- a/backend/static/css/components.css
+++ b/backend/static/css/components.css
@@ -441,6 +441,7 @@ textarea.form-control {
padding: var(--space-4);
backdrop-filter: blur(2px);
animation: overlay-in var(--transition-normal) ease;
+ touch-action: manipulation;
}
@media (min-width: 768px) {
.modal-overlay { align-items: center; }
@@ -812,3 +813,409 @@ textarea.form-control {
/* Leaflet-Attribution ausblenden */
.leaflet-control-attribution { display: none !important; }
+
+/* ============================================================
+ GESUNDHEIT
+ ============================================================ */
+
+/* Header mit KI-Button */
+.health-header {
+ display: flex;
+ justify-content: flex-end;
+ padding: var(--space-3) 0 var(--space-2);
+}
+
+/* Tab-Leiste — Mobile: horizontal scrollbar, Desktop: umbrechen */
+.health-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1) var(--space-1);
+ padding-bottom: var(--space-2);
+ margin-bottom: var(--space-3);
+}
+/* Auf sehr kleinen Screens: scrollen statt umbrechen */
+@media (max-width: 480px) {
+ .health-tabs {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ padding-right: var(--space-4);
+ scrollbar-width: none;
+ }
+ .health-tabs::-webkit-scrollbar { display: none; }
+}
+
+.health-tab {
+ flex-shrink: 0;
+ padding: var(--space-2) var(--space-3);
+ border: 2px solid var(--c-border);
+ border-radius: var(--radius-full);
+ background: var(--c-surface);
+ color: var(--c-text-secondary);
+ font-size: var(--text-sm);
+ font-weight: var(--weight-medium);
+ cursor: pointer;
+ white-space: nowrap;
+ transition: all var(--transition-fast);
+ touch-action: manipulation;
+}
+.health-tab.active {
+ background: var(--c-primary);
+ border-color: var(--c-primary);
+ color: var(--c-text-inverse);
+}
+
+/* Karten-Liste */
+.health-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+/* Einzelne Karte */
+.health-card {
+ background: var(--c-surface);
+ border: 1px solid var(--c-border);
+ border-radius: var(--radius-md);
+ padding: var(--space-4);
+ cursor: pointer;
+ display: flex;
+ gap: var(--space-3);
+ align-items: flex-start;
+ transition: box-shadow var(--transition-fast), transform var(--transition-fast);
+}
+.health-card:active { transform: scale(0.985); }
+.health-card--inactive { opacity: 0.55; }
+
+.health-card-body { flex: 1; min-width: 0; }
+.health-card-title { font-weight: var(--weight-semibold); margin-bottom: var(--space-1); }
+.health-card-meta { font-size: var(--text-sm); color: var(--c-text-secondary); }
+.health-card-next { font-size: var(--text-sm); font-weight: var(--weight-medium); margin-top: var(--space-1); }
+.health-card-note { font-size: var(--text-sm); color: var(--c-text-secondary); margin-top: var(--space-1); }
+
+/* Ampel-Punkt (links an der Karte) */
+.health-card-ampel {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ margin-top: 5px;
+}
+.ampel-green { background: #22c55e; }
+.ampel-yellow { background: #f59e0b; }
+.ampel-red { background: #ef4444; }
+.ampel-grey { background: var(--c-border); }
+
+.ampel-text-green { color: #16a34a; }
+.ampel-text-yellow { color: #d97706; }
+.ampel-text-red { color: #dc2626; }
+
+/* Gruppen-Label (z.B. "Aktuelle Medikamente") */
+.health-group-label {
+ font-size: var(--text-sm);
+ font-weight: var(--weight-semibold);
+ color: var(--c-text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: var(--space-3) 0 var(--space-1);
+}
+
+/* Gewicht-Diagramm-Wrapper */
+.health-chart-wrap {
+ background: var(--c-surface);
+ border: 1px solid var(--c-border);
+ border-radius: var(--radius-md);
+ padding: var(--space-4);
+ margin-bottom: var(--space-4);
+ overflow: hidden;
+}
+
+/* Dokument-Thumbnail und Icon */
+.health-doc-thumb {
+ width: 56px;
+ height: 56px;
+ object-fit: cover;
+ border-radius: var(--radius-sm);
+ flex-shrink: 0;
+}
+.health-doc-icon {
+ width: 56px;
+ height: 56px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 2rem;
+ background: var(--c-surface-2);
+ border-radius: var(--radius-sm);
+ flex-shrink: 0;
+}
+
+/* Detail-Dialog DL */
+.health-detail-dl {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: var(--space-1) var(--space-4);
+ font-size: var(--text-sm);
+}
+.health-detail-dl dt {
+ color: var(--c-text-secondary);
+ font-weight: var(--weight-medium);
+ white-space: nowrap;
+}
+.health-detail-dl dd { margin: 0; }
+
+/* ------------------------------------------------------------
+ DOG SWITCHER
+ Avatar-Leiste in Header (Mobile) + Sidebar-Logo (Desktop)
+ ------------------------------------------------------------ */
+
+/* Aktiver (linker) Hund — primärer Ring */
+.dog-sw-active {
+ width: 34px;
+ height: 34px;
+ border-radius: var(--radius-full);
+ overflow: hidden;
+ background: var(--c-surface-2);
+ flex-shrink: 0;
+ cursor: pointer;
+ border: 2.5px solid var(--c-primary);
+ transition: transform var(--transition-fast),
+ box-shadow var(--transition-fast);
+}
+.dog-sw-active:hover {
+ transform: scale(1.06);
+ box-shadow: 0 0 0 3px var(--c-primary-subtle);
+}
+.dog-sw-active img { width:100%; height:100%; object-fit:cover; display:block; }
+.dog-sw-active span {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ font-size: 17px;
+}
+
+/* "Ban Yaro" Label — füllt den Zwischenraum */
+.dog-sw-title {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Gruppe der inaktiven Hunde (rechts) */
+.dog-sw-others {
+ display: flex;
+ align-items: center;
+ position: relative; /* Anker für Quickpicker-Dropdown */
+ flex-shrink: 0;
+}
+
+/* Inaktiver Hund-Avatar */
+.dog-sw-other {
+ width: 28px;
+ height: 28px;
+ border-radius: var(--radius-full);
+ overflow: hidden;
+ background: var(--c-surface-2);
+ border: 2px solid var(--c-surface);
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: transform var(--transition-fast);
+ position: relative;
+}
+.dog-sw-other:hover { transform: scale(1.12); }
+.dog-sw-other img { width:100%; height:100%; object-fit:cover; display:block; }
+.dog-sw-other span {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ font-size: 14px;
+}
+
+/* Gestapelter Avatar-Stack (3+ Hunde) */
+.dog-sw-stack {
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+}
+.dog-sw-stack .dog-sw-other { margin-left: -9px; }
+.dog-sw-stack .dog-sw-other:first-child { margin-left: 0; }
+.dog-sw-stack .dog-sw-other--0 { z-index: 3; }
+.dog-sw-stack .dog-sw-other--1 { z-index: 2; }
+.dog-sw-stack .dog-sw-other--2 { z-index: 1; }
+.dog-sw-stack:hover .dog-sw-other { filter: brightness(1.05); }
+
+/* +N Überlauf-Badge */
+.dog-sw-more {
+ width: 28px;
+ height: 28px;
+ border-radius: var(--radius-full);
+ background: var(--c-surface-2);
+ border: 2px solid var(--c-surface);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 9px;
+ font-weight: var(--weight-bold);
+ color: var(--c-text-secondary);
+ margin-left: -9px;
+ position: relative;
+ z-index: 0;
+}
+
+/* Quickpicker-Dropdown */
+.dog-quickpick {
+ position: absolute;
+ top: calc(100% + 10px);
+ right: 0;
+ background: var(--c-surface);
+ border: 1px solid var(--c-border-light);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ padding: var(--space-2);
+ min-width: 170px;
+ z-index: 600;
+}
+.dog-quickpick.hidden { display: none; }
+
+.dog-qp-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ font-size: var(--text-sm);
+ font-weight: var(--weight-medium);
+ color: var(--c-text);
+ transition: background var(--transition-fast);
+ -webkit-tap-highlight-color: transparent;
+}
+.dog-qp-item:hover { background: var(--c-bg); }
+
+.dog-qp-av {
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-full);
+ overflow: hidden;
+ background: var(--c-surface-2);
+ flex-shrink: 0;
+}
+.dog-qp-av img { width:100%; height:100%; object-fit:cover; display:block; }
+.dog-qp-av span {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ font-size: 16px;
+}
+
+/* Sidebar: größerer aktiver Avatar */
+#sidebar-dog-switcher .dog-sw-active {
+ width: 36px;
+ height: 36px;
+}
+
+/* ------------------------------------------------------------
+ DIARY — Multi-Dog (Hunde-Auswahl im Formular + Karten-Anzeige)
+ ------------------------------------------------------------ */
+
+/* Avatar-Reihe in der Tagebuch-Karte */
+.diary-dog-row {
+ display: flex;
+ align-items: center;
+ gap: -4px; /* überlappend via margin */
+ margin-top: var(--space-2);
+ flex-wrap: wrap;
+ gap: var(--space-1);
+}
+
+.diary-dog-av {
+ width: 22px;
+ height: 22px;
+ border-radius: var(--radius-full);
+ overflow: hidden;
+ background: var(--c-surface-2);
+ border: 1.5px solid var(--c-surface);
+ flex-shrink: 0;
+}
+.diary-dog-av img { width:100%; height:100%; object-fit:cover; display:block; }
+.diary-dog-av span {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ font-size: 12px;
+}
+
+/* Hunde-Chip in der Detail-Ansicht */
+.diary-detail-dogs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
+}
+
+.diary-dog-chip {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: 3px 8px 3px 4px;
+ background: var(--c-surface-2);
+ border-radius: var(--radius-full);
+ font-size: var(--text-xs);
+ font-weight: var(--weight-medium);
+ color: var(--c-text-secondary);
+}
+.diary-dog-chip .diary-dog-av {
+ width: 20px;
+ height: 20px;
+}
+
+/* Hunde-Picker im Formular */
+.diary-dog-picker {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.diary-dog-pick-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border: 1.5px solid var(--c-border-light);
+ border-radius: var(--radius-full);
+ cursor: pointer;
+ font-size: var(--text-sm);
+ font-weight: var(--weight-medium);
+ color: var(--c-text-secondary);
+ transition: border-color var(--transition-fast),
+ background var(--transition-fast),
+ color var(--transition-fast);
+ -webkit-tap-highlight-color: transparent;
+ user-select: none;
+}
+.diary-dog-pick-item input { display: none; }
+
+.diary-dog-pick-item .diary-dog-av {
+ width: 26px;
+ height: 26px;
+}
+
+.diary-dog-pick-item:hover {
+ border-color: var(--c-primary);
+ color: var(--c-text);
+}
+
+.diary-dog-pick-item.checked {
+ border-color: var(--c-primary);
+ background: var(--c-primary-subtle);
+ color: var(--c-primary-dark);
+ font-weight: var(--weight-semibold);
+}
diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css
index 88fe171..2682530 100644
--- a/backend/static/css/layout.css
+++ b/backend/static/css/layout.css
@@ -58,6 +58,16 @@
flex: 1;
}
+/* Dog Switcher Container im Header */
+#header-dog-switcher {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+ overflow: visible;
+}
+
.header-back {
display: flex;
align-items: center;
@@ -201,7 +211,7 @@
background: var(--c-surface);
border-right: 1px solid var(--c-border-light);
flex-direction: column;
- overflow-y: auto;
+ overflow: hidden; /* Sidebar selbst scrollt nicht */
box-shadow: var(--shadow-sm);
}
@@ -229,14 +239,31 @@
color: var(--c-text);
}
-.sidebar-nav {
- flex: 1;
- padding: var(--space-4) var(--space-2);
- display: flex;
- flex-direction: column;
- gap: var(--space-1);
+.sidebar-add {
+ padding: var(--space-4) var(--space-4) var(--space-2);
+ flex-shrink: 0;
}
+.sidebar-nav {
+ flex: 1;
+ padding: var(--space-2) var(--space-2) var(--space-4);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ overflow-y: auto;
+ min-height: 0; /* wichtig: flex-child darf kleiner werden als Inhalt */
+ /* Firefox */
+ scrollbar-width: thin;
+ scrollbar-color: var(--c-primary) var(--c-surface);
+}
+.sidebar-nav::-webkit-scrollbar { width: 6px; }
+.sidebar-nav::-webkit-scrollbar-track { background: var(--c-surface); }
+.sidebar-nav::-webkit-scrollbar-thumb {
+ background: var(--c-primary);
+ border-radius: 3px;
+}
+.sidebar-nav::-webkit-scrollbar-thumb:hover { background: var(--c-primary-dark); }
+
.sidebar-section-label {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
@@ -270,6 +297,12 @@
color: var(--c-primary-dark);
font-weight: var(--weight-semibold);
}
+/* User-Eintrag bekommt nie den Active-Stil */
+.sidebar-item--user.active {
+ background: transparent;
+ color: var(--c-text-secondary);
+ font-weight: var(--weight-medium);
+}
.sidebar-item-icon {
font-size: 18px;
width: 24px;
@@ -291,11 +324,7 @@
padding: 0 var(--space-1);
}
-.sidebar-footer {
- padding: var(--space-4) var(--space-2);
- border-top: 1px solid var(--c-border-light);
- flex-shrink: 0;
-}
+/* sidebar-footer entfernt — Einstellungen/Konto sind jetzt Teil der scrollbaren Nav */
/* ------------------------------------------------------------
5. PAGE WRAPPER (inneres Layout der Seiten)
diff --git a/backend/static/icons/favicon-16.png b/backend/static/icons/favicon-16.png
new file mode 100644
index 0000000..40b9c1f
Binary files /dev/null and b/backend/static/icons/favicon-16.png differ
diff --git a/backend/static/icons/favicon-32.png b/backend/static/icons/favicon-32.png
new file mode 100644
index 0000000..7667efc
Binary files /dev/null and b/backend/static/icons/favicon-32.png differ
diff --git a/backend/static/icons/favicon.ico b/backend/static/icons/favicon.ico
new file mode 100644
index 0000000..9c22a1c
Binary files /dev/null and b/backend/static/icons/favicon.ico differ
diff --git a/backend/static/icons/icon-180.png b/backend/static/icons/icon-180.png
new file mode 100644
index 0000000..57a5fa6
Binary files /dev/null and b/backend/static/icons/icon-180.png differ
diff --git a/backend/static/icons/icon-192.png b/backend/static/icons/icon-192.png
new file mode 100644
index 0000000..2554fff
Binary files /dev/null and b/backend/static/icons/icon-192.png differ
diff --git a/backend/static/icons/icon-512.png b/backend/static/icons/icon-512.png
new file mode 100644
index 0000000..58dc5fe
Binary files /dev/null and b/backend/static/icons/icon-512.png differ
diff --git a/backend/static/index.html b/backend/static/index.html
index d5c5e22..f0fd842 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -6,9 +6,14 @@
+
+
+
+
+
-
+
@@ -30,17 +35,23 @@