diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index f6fdb0d..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339} \ No newline at end of file diff --git a/PROJEKT.md b/PROJEKT.md index da216fe..246ea6b 100644 --- a/PROJEKT.md +++ b/PROJEKT.md @@ -46,50 +46,6 @@ Maps: Leaflet.js + OpenStreetMap (kostenlos, kein Google-Lock) --- -## Implementierungsstand (aktuell: 2026-04-25, SW by-v405, APP_VER 385) - -### Sprint 12+13 (2026-04-25) ✅ - -#### Tagebuch — Day-One-Redesign -- Listenansicht: Wochentag-Kürzel + große Tageszahl links, Titel fett, Textvorschau, Meta-Zeile (Zeit · Ort · Wetter), Thumbnail quadratisch rechts -- Vier Ansichten: Liste, Medien (3-Spalten-Mosaik), Kalender (mit Fotos auf Tagen), Karte (Leaflet alle GPS-Standorte) -- Kalender: Doppelpfeil-Sprungbuttons «/» zum nächsten Monat mit Einträgen -- Karten-Ansicht: Foto-Marker, Popup-Vorschau, Klick öffnet Eintrag direkt -- Detail-Ansicht: inline im Content-Bereich (nicht mehr als Fullscreen-Overlay), Sidebar bleibt sichtbar -- Detail: Hero-Foto (vollständig sichtbar, object-fit:contain), Thumbnails, 2-Spalten-Layout Desktop (Text + Karte/POI) -- Detail: Karte zeigt GPS-Position, POI-Liste darunter ("In der Nähe") -- Lightbox: Back-Button + Prev/Next in Bottom-Bar, Safe-Area für Querformat -- Stats-Leiste: Einträge/Medien/Tage (Gesamtzahlen vom Backend), View-Switcher, kompakt auf Mobile -- "Weitere laden" nur in Listenansicht sichtbar - -#### Tagebuch — Daten & Import -- EXIF-GPS-Extraktion beim Foto-Upload (Pillow), auto-Wetter+POI bei GPS aus EXIF -- Wetter (Open-Meteo Archive-API historisch): 106 Einträge retroaktiv angereichert -- POIs (osm_pois DB-Cache, 437k Einträge): 85 Einträge retroaktiv angereichert -- NoteStation-Import Fix: Fotos in diary_media statt altem media_url-Feld -- Migration: 80 importierte media_url-Einträge in diary_media (94 statt 15 Medien für Ban Yaro) -- Neue API-Endpoints: /diary/stats, /diary/calendar, /diary/locations - -#### Notiz-Feature -- Generische notes-Tabelle (parent_type + parent_id + meta_json) -- REST-API /api/notes mit GET/POST/PATCH/DELETE -- API.notes in api.js -- 📝-Button in: Übungen, Gesundheit, Tagebuch, Routen, Events, Gassi-Treffen, Sitting, Erste Hilfe -- Notizblock-Seite: Filter nach Rubrik, Suche, Sortierung, KI-Muster-Erkennung (abschaltbar) -- KI-Toggle in Einstellungen - -#### Design & Icons -- fill:currentColor Fix für SVGs ohne ph-icon-Klasse (welcome.js, onboarding.js, friends.js) -- --c-icon CSS-Variable, --c-text-muted in Dark Mode aufgehellt (#9A8878) -- 15+ neue Phosphor-Icons: note-pencil, images, caret-left/right/double, coffee, bed, tree, church, etc. -- Phosphor-Workflow: fill-Variante aus lokaler Kopie /icons/phosphor-icons/SVGs/fill/ - -#### Infrastruktur -- CSS Network-First im Service Worker (kein iOS-Caching-Problem mehr) -- Cache-Control-Middleware: versioned URLs immutable, andere no-cache -- Python open(w)-vor-read Bug dokumentiert (leert Datei) -- Scheduler: Wiki-Anreicherungs-Jobs entfernt (abgeschlossen) - ## Implementierungsstand (aktuell: 2026-04-25, SW by-v370, APP_VER 355) ### Sprint 11 (2026-04-25) ✅ @@ -266,7 +222,7 @@ Maps: Leaflet.js + OpenStreetMap (kostenlos, kein Google-Lock) #### 1.2 Gesundheit & Impfpass - [ ] Impfungen, Entwurmungen, Tierarztbesuche digital - [ ] Medikamenten-Reminder (Push Notification) -- [x] Gewichtsverlauf-Chart ✅ +- [ ] Gewichtsverlauf-Chart - [ ] Einfacher Symptom-Checker (KI-gestützt, Triage: beobachten/Tierarzt/Notfall) #### 1.3 Giftköder-Alarm diff --git a/backend/auth.py b/backend/auth.py index beedb65..9e01700 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -87,7 +87,7 @@ def get_current_user( user_id = int(payload["sub"]) with db() as conn: row = conn.execute( - "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason FROM users WHERE id=?", (user_id,) ).fetchone() diff --git a/backend/database.py b/backend/database.py index e4c43ed..4d656d1 100644 --- a/backend/database.py +++ b/backend/database.py @@ -534,13 +534,6 @@ def _migrate(conn_factory): ("pflege_tipps", "fell_pflege_art", "TEXT"), # Wiki-Foto-Einreichungen: Bildrechte-Bestätigung ("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"), - # Tagebuch: Wetter + POI-Metadaten beim Eintrag - ("diary", "weather_json", "TEXT"), - ("diary", "poi_json", "TEXT"), - # Notizen: Ort + Label + KI-Assistent User-Setting - ("notes", "location_name", "TEXT"), - ("notes", "parent_label", "TEXT"), - ("users", "notes_ki_enabled", "INTEGER NOT NULL DEFAULT 1"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -1138,22 +1131,3 @@ def _migrate(conn_factory): CREATE INDEX IF NOT EXISTS idx_ki_daily_source ON ki_daily_calls(date, source); """) logger.info("Migration: ki_daily_calls.source bereit.") - - # Notizen: generische polymorphe Notiz-Tabelle - conn.executescript(""" - CREATE TABLE IF NOT EXISTS notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - parent_type TEXT NOT NULL, - parent_id INTEGER NOT NULL, - text TEXT NOT NULL DEFAULT '', - meta_json TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - CREATE INDEX IF NOT EXISTS idx_notes_parent - ON notes(parent_type, parent_id, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_notes_user - ON notes(user_id, created_at DESC); - """) - logger.info("Migration: notes Tabelle bereit.") diff --git a/backend/main.py b/backend/main.py index d9e0d71..4cfad18 100644 --- a/backend/main.py +++ b/backend/main.py @@ -80,24 +80,6 @@ class _UploadSizeMiddleware(BaseHTTPMiddleware): app.add_middleware(_UploadSizeMiddleware) -class _CacheControlMiddleware(BaseHTTPMiddleware): - """Setzt Cache-Control-Header für statische Assets. - CSS/JS: no-cache (ETag-Validierung) — iOS cached sonst ewig ohne Ablaufdatum. - Versioned Assets (?v=…): immutable — URL ändert sich bei Updates. - """ - async def dispatch(self, request: Request, call_next): - response = await call_next(request) - path = request.url.path - if path.startswith(("/css/", "/js/", "/icons/phosphor.svg")): - if "v=" in str(request.url.query): - response.headers["Cache-Control"] = "public, max-age=31536000, immutable" - else: - response.headers["Cache-Control"] = "no-cache, must-revalidate" - return response - -app.add_middleware(_CacheControlMiddleware) - - # ------------------------------------------------------------------ # API-Router registrieren (werden nach und nach hinzugefügt) # ------------------------------------------------------------------ @@ -140,7 +122,6 @@ from routes.praise import router as praise_router from routes.weather import router as weather_router from routes.social import router as social_router from routes.moderation import router as moderation_router -from routes.notes import router as notes_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -182,7 +163,6 @@ app.include_router(achievements_router, prefix="/api/achievements", tags= app.include_router(training_router, prefix="/api/training", tags=["Training"]) app.include_router(praise_router, prefix="/api/praise", tags=["Praise"]) app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"]) -app.include_router(notes_router, prefix="/api/notes", tags=["Notes"]) # ------------------------------------------------------------------ diff --git a/backend/media_utils.py b/backend/media_utils.py index 10f1f5f..4b70300 100644 --- a/backend/media_utils.py +++ b/backend/media_utils.py @@ -117,36 +117,6 @@ def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]: pass -def extract_gps_from_exif(data: bytes) -> tuple | None: - """EXIF-GPS aus Bilddaten lesen. Gibt (lat, lon) zurück oder None.""" - try: - from PIL import Image - img = Image.open(io.BytesIO(data)) - exif = img._getexif() - if not exif: - return None - gps = exif.get(34853) # GPSInfo tag - if not gps: - return None - lat_dms = gps.get(2) - lon_dms = gps.get(4) - lat_ref = gps.get(1, 'N') - lon_ref = gps.get(3, 'E') - if not lat_dms or not lon_dms: - return None - - def dms(v): - return float(v[0]) + float(v[1]) / 60 + float(v[2]) / 3600 - - lat = dms(lat_dms) * (-1 if lat_ref == 'S' else 1) - lon = dms(lon_dms) * (-1 if lon_ref == 'W' else 1) - if not (-90 <= lat <= 90 and -180 <= lon <= 180): - return None - return round(lat, 6), round(lon, 6) - except Exception: - return None - - def convert_media(data: bytes, filename: str) -> Tuple[bytes, str]: """Convert HEIC→JPEG and MOV/AVI/M4V→MP4; pass everything else through.""" ext = os.path.splitext(filename or "")[1].lower() diff --git a/backend/routes/diary.py b/backend/routes/diary.py index e2ead8f..b469bbe 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -1,17 +1,14 @@ """BAN YARO — Tagebuch Routes""" -import os, uuid, json, math, logging +import os, uuid, json, math 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, require_admin +from auth import get_current_user import ki as KI import httpx -import weather as weather_mod -from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif - -logger = logging.getLogger(__name__) +from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -145,69 +142,6 @@ def _entry_dict(row, dog_ids_map: dict, media_map: dict = None) -> dict: return e -@router.get("/{dog_id}/diary/stats") -async def diary_stats(dog_id: int, user=Depends(get_current_user)): - """Gesamtstatistik für das Tagebuch (unabhängig von Pagination).""" - with db() as conn: - _can_read_dog(dog_id, user["id"], conn) - total = conn.execute( - "SELECT COUNT(*) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id " - "WHERE (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id) - ).fetchone()[0] - photos = conn.execute( - "SELECT COUNT(*) FROM diary_media dm " - "JOIN diary d ON d.id=dm.diary_id LEFT JOIN diary_dogs dd ON dd.diary_id=d.id " - "WHERE (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id) - ).fetchone()[0] - days = conn.execute( - "SELECT COUNT(DISTINCT d.datum) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id " - "WHERE d.datum IS NOT NULL AND (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id) - ).fetchone()[0] - return {"entries": total, "photos": photos, "days": days} - - -@router.get("/{dog_id}/diary/calendar") -async def diary_calendar(dog_id: int, user=Depends(get_current_user)): - """Alle Einträge minimal für Kalenderansicht: id, datum, cover_url.""" - with db() as conn: - _can_read_dog(dog_id, user["id"], conn) - rows = conn.execute( - """SELECT DISTINCT d.id, d.datum, - (SELECT dm.url FROM diary_media dm - WHERE dm.diary_id=d.id AND dm.media_type='image' - ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url - FROM diary d - LEFT JOIN diary_dogs dd ON dd.diary_id = d.id - WHERE (d.dog_id=? OR dd.dog_id=?) - AND d.datum IS NOT NULL - ORDER BY d.datum DESC""", - (dog_id, dog_id) - ).fetchall() - return [dict(r) for r in rows] - - -@router.get("/{dog_id}/diary/locations") -async def diary_locations(dog_id: int, user=Depends(get_current_user)): - """Alle Tagebucheinträge mit GPS — minimal für Karten-Ansicht.""" - with db() as conn: - _can_read_dog(dog_id, user["id"], conn) - rows = conn.execute( - """SELECT DISTINCT d.id, d.datum, d.titel, d.gps_lat, d.gps_lon, - d.location_name, d.weather_json, - (SELECT dm.url FROM diary_media dm - WHERE dm.diary_id=d.id AND dm.media_type='image' - ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url, - (SELECT COUNT(*) FROM diary_media dm WHERE dm.diary_id=d.id) AS media_count - FROM diary d - LEFT JOIN diary_dogs dd ON dd.diary_id = d.id - WHERE (d.dog_id=? OR dd.dog_id=?) - AND d.gps_lat IS NOT NULL AND d.gps_lon IS NOT NULL - ORDER BY d.datum DESC""", - (dog_id, dog_id) - ).fetchall() - return [dict(r) for r in rows] - - @router.get("/{dog_id}/diary") async def list_diary(dog_id: int, limit: int = 20, offset: int = 0, q: Optional[str] = None, milestone: int = 0, @@ -292,95 +226,10 @@ async def create_diary(dog_id: int, data: DiaryCreate, _set_dog_ids(conn, entry["id"], all_dogs) dogs_map = _fetch_dog_ids(conn, [entry["id"]]) media_map = _fetch_media_items(conn, [entry["id"]]) - entry_id = entry["id"] - - # Wetter + POIs asynchron nach dem DB-Commit holen (außerhalb des with-Blocks) - if data.gps_lat is not None and data.gps_lon is not None: - weather_json = None - poi_json = None - - # Wetter holen - try: - wd = await weather_mod.get_weather_for_location(data.gps_lat, data.gps_lon) - weather_json = json.dumps(wd) - except Exception as exc: - logger.warning("Wetter-Abfrage beim Diary-Create fehlgeschlagen: %s", exc) - - # POIs holen - try: - pois = await _fetch_pois_for_coords(data.gps_lat, data.gps_lon, limit=5) - if pois: - poi_json = json.dumps(pois) - except Exception as exc: - logger.warning("POI-Abfrage beim Diary-Create fehlgeschlagen: %s", exc) - - # In DB speichern und Entry aktualisieren - if weather_json is not None or poi_json is not None: - with db() as conn: - conn.execute( - "UPDATE diary SET weather_json=?, poi_json=? WHERE id=?", - (weather_json, poi_json, entry_id) - ) - entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone() return _entry_dict(entry, dogs_map, media_map) -async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list: - """Holt POIs für Koordinaten via Overpass (analog zu nearby_places, aber ohne DB/Auth).""" - results = [] - try: - async with httpx.AsyncClient(timeout=6) as client: - def _overpass_q(radius): - return ( - f'[out:json][timeout:6];' - f'(' - f' node["name"]["tourism"](around:{radius},{lat},{lon});' - f' node["name"]["historic"](around:{radius},{lat},{lon});' - f' node["name"]["leisure"](around:{radius},{lat},{lon});' - f' node["name"]["amenity"](around:{radius},{lat},{lon});' - f' node["name"]["shop"](around:{radius},{lat},{lon});' - f' way["name"]["tourism"](around:{radius},{lat},{lon});' - f' way["name"]["historic"](around:{radius},{lat},{lon});' - f' way["name"]["leisure"](around:{radius},{lat},{lon});' - f');' - f'out center;' - ) - ov = await client.post( - "https://overpass-api.de/api/interpreter", - data={"data": _overpass_q(800)}, - headers={"User-Agent": "BanYaro/1.0"}, - ) - elements = ov.json().get("elements", []) if ov.status_code == 200 else [] - if not elements: - ov2 = await client.post( - "https://overpass-api.de/api/interpreter", - data={"data": _overpass_q(2000)}, - headers={"User-Agent": "BanYaro/1.0"}, - ) - elements = ov2.json().get("elements", []) if ov2.status_code == 200 else [] - seen = set() - for el in elements: - n = el.get("tags", {}).get("name") - if not n or n.lower() in seen: - continue - seen.add(n.lower()) - elat = el.get("lat") or el.get("center", {}).get("lat") - elon = el.get("lon") or el.get("center", {}).get("lon") - if elat and elon: - km = _haversine_km(lat, lon, elat, elon) - typ = next((el["tags"].get(k) for k in - ["tourism", "historic", "leisure", "amenity", "shop"] - if el["tags"].get(k)), "place") - results.append({"name": n, "type": typ, - "distance_m": int(km * 1000)}) - if len(results) >= limit: - break - except Exception as exc: - logger.debug("_fetch_pois_for_coords Fehler: %s", exc) - return results[:limit] - - def _haversine_km(lat1, lon1, lat2, lon2) -> float: R = 6371 dlat = math.radians(lat2 - lat1) @@ -659,11 +508,6 @@ async def upload_media(dog_id: int, entry_id: int, media_url = f"/media/diary/{filename}" - # EXIF-GPS aus Bild extrahieren (nur bei Bilddateien) - exif_gps = None - if media_type == "image": - exif_gps = extract_gps_from_exif(raw_data) - with db() as conn: # sort_order = nächste freie Position max_order = conn.execute( @@ -681,38 +525,8 @@ async def upload_media(dog_id: int, entry_id: int, (entry_id,) ).fetchone()["id"] - # GPS aus EXIF in den Eintrag schreiben, wenn noch keine Koordinaten vorhanden - gps_written = False - if exif_gps: - existing = conn.execute( - "SELECT gps_lat FROM diary WHERE id=?", (entry_id,) - ).fetchone() - if existing and existing["gps_lat"] is None: - conn.execute( - "UPDATE diary SET gps_lat=?, gps_lon=? WHERE id=?", - (exif_gps[0], exif_gps[1], entry_id) - ) - gps_written = True - - # Wetter + POI nachladen wenn GPS frisch gesetzt - if gps_written and exif_gps: - try: - wd = await weather_mod.get_weather_for_location(exif_gps[0], exif_gps[1]) - pois = await _fetch_pois_for_coords(exif_gps[0], exif_gps[1], limit=5) - with db() as conn: - conn.execute( - "UPDATE diary SET weather_json=COALESCE(weather_json,?), poi_json=COALESCE(poi_json,?) WHERE id=?", - (json.dumps(wd) if wd else None, json.dumps(pois) if pois else None, entry_id) - ) - except Exception as e: - logger.warning("EXIF-GPS Wetter/POI Fehler: %s", e) - - resp = {"id": new_id, "url": media_url, "media_type": media_type, + return {"id": new_id, "url": media_url, "media_type": media_type, "sort_order": max_order + 1, "is_cover": is_cover} - if exif_gps: - resp["exif_lat"] = exif_gps[0] - resp["exif_lon"] = exif_gps[1] - return resp @router.delete("/{dog_id}/diary/{entry_id}/media/{media_id}", status_code=204) @@ -773,55 +587,3 @@ async def set_cover_media(dog_id: int, entry_id: int, media_id: int, conn.execute("UPDATE diary_media SET is_cover=0 WHERE diary_id=?", (entry_id,)) conn.execute("UPDATE diary_media SET is_cover=1 WHERE id=?", (media_id,)) return {"ok": True} - - -# ------------------------------------------------------------------ -# Admin: retroaktive Metadaten-Anreicherung bestehender Einträge -# ------------------------------------------------------------------ -@router.post("/admin/enrich-metadata", status_code=200) -async def admin_enrich_diary_metadata(limit: int = 20, _=Depends(require_admin)): - """Reichert bestehende Tagebucheinträge mit GPS-Koordinaten mit Wetter + POI nach.""" - with db() as conn: - rows = conn.execute( - """SELECT id, gps_lat, gps_lon FROM diary - WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL - AND (weather_json IS NULL OR poi_json IS NULL) - LIMIT ?""", - (limit,) - ).fetchall() - - enriched = 0 - skipped = 0 - for row in rows: - entry_id, lat, lon = row["id"], row["gps_lat"], row["gps_lon"] - weather_json = None - poi_json = None - try: - wd = await weather_mod.get_weather_for_location(lat, lon) - weather_json = json.dumps(wd) - except Exception as e: - logger.warning("enrich-metadata Wetter id=%s: %s", entry_id, e) - try: - pois = await _fetch_pois_for_coords(lat, lon, limit=5) - if pois: - poi_json = json.dumps(pois) - except Exception as e: - logger.warning("enrich-metadata POI id=%s: %s", entry_id, e) - - if weather_json is not None or poi_json is not None: - with db() as conn: - conn.execute( - "UPDATE diary SET weather_json=COALESCE(weather_json,?), poi_json=COALESCE(poi_json,?) WHERE id=?", - (weather_json, poi_json, entry_id) - ) - enriched += 1 - else: - skipped += 1 - - with db() as conn: - remaining = conn.execute( - """SELECT COUNT(*) FROM diary - WHERE gps_lat IS NOT NULL AND (weather_json IS NULL OR poi_json IS NULL)""" - ).fetchone()[0] - - return {"enriched": enriched, "skipped": skipped, "remaining": remaining} diff --git a/backend/routes/import_data.py b/backend/routes/import_data.py index 8dbe887..76c3099 100644 --- a/backend/routes/import_data.py +++ b/backend/routes/import_data.py @@ -155,9 +155,9 @@ async def import_notestation( (entry_id, dog_id), ) - # Anhänge in diary_media speichern (statt veraltetem media_url-Feld) + # Erstes Bild speichern attachments = note.get("attachment") or {} - first = True + media_url = None for att in attachments.values(): md5 = att.get("md5", "") mime = att.get("type", "image/jpeg") @@ -165,11 +165,13 @@ async def import_notestation( continue media_url = _save_image_from_zip(zf, md5, mime) if media_url: - conn.execute( - "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)", - (entry_id, media_url, "image", 0 if first else 1, 1 if first else 0), - ) - first = False + break + + if media_url: + conn.execute( + "UPDATE diary SET media_url=? WHERE id=?", + (media_url, entry_id), + ) imported += 1 diff --git a/backend/routes/notes.py b/backend/routes/notes.py deleted file mode 100644 index a85a2a2..0000000 --- a/backend/routes/notes.py +++ /dev/null @@ -1,263 +0,0 @@ -"""BAN YARO — Notizen Routes""" - -import json -import logging -from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel -from typing import Optional, Any, List -from database import db -from auth import get_current_user - -router = APIRouter() -logger = logging.getLogger(__name__) - - -# ------------------------------------------------------------------ -# Schemas -# ------------------------------------------------------------------ -class NoteCreate(BaseModel): - text: str - meta_json: Optional[Any] = None - location_name: Optional[str] = None - parent_label: Optional[str] = None - - -class NoteUpdate(BaseModel): - text: Optional[str] = None - meta_json: Optional[Any] = None - location_name: Optional[str] = None - parent_label: Optional[str] = None - - -# ------------------------------------------------------------------ -# Hilfsfunktionen -# ------------------------------------------------------------------ -def _serialize(row) -> dict: - d = dict(row) - if d.get("meta_json") and isinstance(d["meta_json"], str): - try: - d["meta_json"] = json.loads(d["meta_json"]) - except Exception: - pass - return d - - -# ------------------------------------------------------------------ -# GET /api/notes — Gesamt-Notizblock mit Filtern -# Alias: GET /api/notes/all/0 (Rückwärtskompatibilität) -# WICHTIG: Diese Route muss VOR /{parent_type}/{parent_id} stehen! -# ------------------------------------------------------------------ -@router.get("") -async def list_all_notes_filtered( - parent_type: Optional[List[str]] = Query(default=None), - date_from: Optional[str] = Query(default=None), - date_to: Optional[str] = Query(default=None), - q: Optional[str] = Query(default=None), - sort: Optional[str] = Query(default="date_desc"), - user=Depends(get_current_user), -): - """Alle Notizen des Users mit optionalen Filtern.""" - conditions = ["user_id=?"] - params: list = [user["id"]] - - if parent_type: - placeholders = ",".join("?" * len(parent_type)) - conditions.append(f"parent_type IN ({placeholders})") - params.extend(parent_type) - - if date_from: - conditions.append("DATE(created_at) >= ?") - params.append(date_from) - - if date_to: - conditions.append("DATE(created_at) <= ?") - params.append(date_to) - - if q: - conditions.append("(text LIKE ? OR COALESCE(parent_label,'') LIKE ?)") - like = f"%{q}%" - params.extend([like, like]) - - where = " AND ".join(conditions) - - if sort == "rubrik": - order = "parent_type ASC, created_at DESC" - elif sort == "ort": - order = "CASE WHEN location_name IS NULL OR location_name='' THEN 1 ELSE 0 END ASC, location_name ASC, created_at DESC" - elif sort == "date_asc": - order = "created_at ASC" - else: - order = "created_at DESC" - - with db() as conn: - rows = conn.execute( - f"SELECT * FROM notes WHERE {where} ORDER BY {order}", - params - ).fetchall() - - return [_serialize(r) for r in rows] - - -@router.get("/all/0") -async def list_all_notes(user=Depends(get_current_user)): - """Alias für Rückwärtskompatibilität.""" - with db() as conn: - rows = conn.execute( - "SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC", - (user["id"],) - ).fetchall() - return [_serialize(r) for r in rows] - - -# ------------------------------------------------------------------ -# POST /api/notes/ki-analyse -# WICHTIG: Fixe Route MUSS vor /{parent_type}/{parent_id} stehen! -# ------------------------------------------------------------------ -@router.post("/ki-analyse") -async def ki_analyse(user=Depends(get_current_user)): - """KI analysiert die Notizen des Users und gibt Muster/Vorschläge zurück.""" - with db() as conn: - # User-Setting prüfen - setting = conn.execute( - "SELECT notes_ki_enabled FROM users WHERE id=?", - (user["id"],) - ).fetchone() - - if not setting or not setting["notes_ki_enabled"]: - raise HTTPException(403, "KI-Assistent ist deaktiviert.") - - with db() as conn: - rows = conn.execute( - """SELECT text, parent_type, parent_label, location_name, created_at - FROM notes - WHERE user_id=? - ORDER BY created_at DESC - LIMIT 50""", - (user["id"],) - ).fetchall() - - note_count = len(rows) - if note_count == 0: - return {"suggestions": "", "note_count": 0} - - notes_data = [dict(r) for r in rows] - - prompt = ( - "Du bist ein freundlicher Assistent für Hundebesitzer. " - "Analysiere diese Notizen und erkenne Muster (Gesundheit, Training, Verhalten, " - "Lieblingsrouten, saisonale Besonderheiten). " - "Gib 2-4 kurze, konkrete Vorschläge auf Deutsch. " - "Keine langen Texte, bullet points. " - f"Daten: {json.dumps(notes_data, ensure_ascii=False)}" - ) - - try: - import ki as ki_module - suggestions, _ = await ki_module.complete( - prompt, - requires_premium=False, - user_is_premium=False, - ) - except Exception as e: - logger.warning("KI-Analyse fehlgeschlagen: %s", e) - suggestions = "" - - return {"suggestions": suggestions, "note_count": note_count} - - -# ------------------------------------------------------------------ -# GET /api/notes/{parent_type}/{parent_id} -# parent_id kann ein Integer oder ein String-Schlüssel sein. -# SQLite ist dynamisch getypt — wir übergeben den Wert als Text. -# ------------------------------------------------------------------ -@router.get("/{parent_type}/{parent_id}") -async def list_notes(parent_type: str, parent_id: str, - user=Depends(get_current_user)): - with db() as conn: - rows = conn.execute( - """SELECT * FROM notes - WHERE user_id=? AND parent_type=? AND CAST(parent_id AS TEXT)=? - ORDER BY created_at DESC""", - (user["id"], parent_type, parent_id) - ).fetchall() - return [_serialize(r) for r in rows] - - -# ------------------------------------------------------------------ -# POST /api/notes/{parent_type}/{parent_id} -# ------------------------------------------------------------------ -@router.post("/{parent_type}/{parent_id}", status_code=201) -async def create_note(parent_type: str, parent_id: str, data: NoteCreate, - user=Depends(get_current_user)): - if not data.text.strip(): - raise HTTPException(400, "Notiz darf nicht leer sein.") - - meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None - now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") - - with db() as conn: - conn.execute( - """INSERT INTO notes - (user_id, parent_type, parent_id, text, meta_json, - location_name, parent_label, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", - (user["id"], parent_type, parent_id, data.text.strip(), meta_str, - data.location_name, data.parent_label, now, now) - ) - row = conn.execute( - "SELECT * FROM notes WHERE user_id=? AND parent_type=? AND parent_id=? ORDER BY id DESC LIMIT 1", - (user["id"], parent_type, parent_id) - ).fetchone() - return _serialize(row) - - -# ------------------------------------------------------------------ -# PATCH /api/notes/{id} -# ------------------------------------------------------------------ -@router.patch("/{note_id}") -async def update_note(note_id: int, data: NoteUpdate, - user=Depends(get_current_user)): - with db() as conn: - note = conn.execute( - "SELECT * FROM notes WHERE id=? AND user_id=?", (note_id, user["id"]) - ).fetchone() - if not note: - raise HTTPException(404, "Notiz nicht gefunden.") - - updates = {} - if data.text is not None: - if not data.text.strip(): - raise HTTPException(400, "Notiz darf nicht leer sein.") - updates["text"] = data.text.strip() - if data.meta_json is not None: - updates["meta_json"] = json.dumps(data.meta_json) - if data.location_name is not None: - updates["location_name"] = data.location_name - if data.parent_label is not None: - updates["parent_label"] = data.parent_label - - if not updates: - return _serialize(note) - - updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") - set_clause = ", ".join(f"{k}=?" for k in updates) - values = list(updates.values()) + [note_id] - conn.execute(f"UPDATE notes SET {set_clause} WHERE id=?", values) - row = conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone() - return _serialize(row) - - -# ------------------------------------------------------------------ -# DELETE /api/notes/{id} -# ------------------------------------------------------------------ -@router.delete("/{note_id}", status_code=204) -async def delete_note(note_id: int, user=Depends(get_current_user)): - with db() as conn: - note = conn.execute( - "SELECT id FROM notes WHERE id=? AND user_id=?", (note_id, user["id"]) - ).fetchone() - if not note: - raise HTTPException(404, "Notiz nicht gefunden.") - conn.execute("DELETE FROM notes WHERE id=?", (note_id,)) - return None diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 08f403a..a706949 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -25,7 +25,6 @@ class ProfileUpdate(BaseModel): erfahrung: Optional[str] = None social_link: Optional[str] = None profil_sichtbarkeit: Optional[str] = None - notes_ki_enabled: Optional[int] = None def _load_user(user_id: int) -> dict: diff --git a/backend/scheduler.py b/backend/scheduler.py index 84d5072..c80a583 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -90,6 +90,14 @@ def start(): id="seed_wikidata_startup", replace_existing=True, ) + # Täglich 02:30 Uhr — KI-Anreicherung für 20 noch nicht angereicherte Rassen + _scheduler.add_job( + _job_wiki_enrich, + CronTrigger(hour=2, minute=30), + id="wiki_enrich_nightly", + replace_existing=True, + misfire_grace_time=3600, + ) # Jeden Montag 09:00 — Wöchentlicher Fortschritts-Lober _scheduler.add_job( _job_weekly_praise, @@ -106,8 +114,16 @@ def start(): replace_existing=True, misfire_grace_time=1800, ) + # Einmalig beim Start (nach 90s) — erste 50 Rassen sofort anreichern + _scheduler.add_job( + _job_wiki_enrich_startup, + 'date', + run_date=datetime.now(tz=_TZ) + timedelta(seconds=90), + id="wiki_enrich_startup", + replace_existing=True, + ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start.") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start, Wiki-KI-Anreicherung 02:30.") def stop(): @@ -613,6 +629,35 @@ def _log_job(job_id: str, status: str, result: str): } +# ------------------------------------------------------------------ +# JOB: KI-Anreicherung der Rassen-Daten (nächtlich) +# ------------------------------------------------------------------ +async def _job_wiki_enrich(): + """Reichert alle noch nicht angereicherten Rassen mit KI-Daten an.""" + try: + from scraper.breed_enricher import enrich_breeds + enriched = await enrich_breeds(limit=2000) + msg = f"{enriched} Rassen angereichert" + logger.info(f"Wiki-KI-Anreicherung (nächtlich): {msg}.") + _log_job("wiki_enrich_nightly", "ok", msg) + except Exception as e: + logger.error(f"Wiki-KI-Anreicherung: Fehler: {e}") + _log_job("wiki_enrich_nightly", "error", str(e)) + + +async def _job_wiki_enrich_startup(): + """Beim Start: alle Rassen sofort anreichern.""" + try: + from scraper.breed_enricher import enrich_breeds + enriched = await enrich_breeds(limit=2000) + msg = f"{enriched} Rassen angereichert (Startup)" + logger.info(f"Wiki-KI-Anreicherung (Startup): {msg}.") + _log_job("wiki_enrich_startup", "ok", msg) + except Exception as e: + logger.error(f"Wiki-KI-Anreicherung (Startup): Fehler: {e}") + _log_job("wiki_enrich_startup", "error", str(e)) + + # ------------------------------------------------------------------ # Hilfsfunktion: Lob-Text für einen Hund generieren # ------------------------------------------------------------------ @@ -770,6 +815,12 @@ async def _job_status_report(): metrics = {} try: with db() as conn: + # Rassen-Anreicherung + metrics["rassen_total"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen").fetchone()[0] + metrics["rassen_enriched"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=1").fetchone()[0] + metrics["rassen_mit_foto"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE foto_url IS NOT NULL AND foto_url NOT LIKE 'http%'").fetchone()[0] + metrics["rassen_mit_desc"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE beschreibung IS NOT NULL AND beschreibung != ''").fetchone()[0] + # Züchter try: metrics["zuchter_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0").fetchone()[0] @@ -797,6 +848,16 @@ async def _job_status_report(): logger.error(f"Status-Report: DB-Fehler: {e}") return + # --- Wiki-Fortschritt berechnen --- + total = metrics["rassen_total"] or 1 + enriched = metrics["rassen_enriched"] + pct = round(enriched / total * 100) + remaining = total - enriched + nights_left = (remaining + 19) // 20 # bei 20/Nacht + + bar_filled = round(pct / 5) + progress_bar = "█" * bar_filled + "░" * (20 - bar_filled) + # --- Job-Log-Tabelle --- job_labels = { "health_reminders": "Gesundheits-Erinnerungen", @@ -804,6 +865,8 @@ async def _job_status_report(): "weather_alert": "Wetter-Alert", "milestone_check": "Meilenstein-Check", "import_events": "Event-Import (VDH)", + "wiki_enrich_nightly": "Wiki KI-Anreicherung (nächtlich)", + "wiki_enrich_startup": "Wiki KI-Anreicherung (Startup)", "seed_breeds_startup": "Rassen-Seed (TheDogAPI)", "seed_wikidata_startup":"Rassen-Seed (Wikidata)", "weekly_praise": "Wöchentlicher Lober (Mo 09:00)", @@ -836,6 +899,18 @@ async def _job_status_report():
{now_str} Uhr
+ +
+
Wiki KI-Anreicherung
+
+ {progress_bar} {pct}%
+ ✅ Angereichert: {enriched} / {total}
+ ⏳ Verbleibend: {remaining} Rassen (~{nights_left} Nächte)
+ 📷 Mit lokalem Foto: {metrics['rassen_mit_foto']}
+ 📝 Mit Beschreibung: {metrics['rassen_mit_desc']} +
+
+
Scheduler-Jobs
@@ -872,6 +947,13 @@ async def _job_status_report(): plain = f"""Ban Yaro Status-Report — {now_str} +=== Wiki KI-Anreicherung === +{progress_bar} {pct}% +Angereichert: {enriched}/{total} +Verbleibend: {remaining} Rassen (~{nights_left} Nächte à 20/Nacht) +Mit Foto: {metrics['rassen_mit_foto']} +Mit Beschreibung: {metrics['rassen_mit_desc']} + === Scheduler-Jobs === {job_rows_txt} === Community === diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 875b2f7..3102262 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -963,326 +963,82 @@ html.modal-open { } /* ------------------------------------------------------------ - 12. TAGEBUCH — Day One Style + 12. TAGEBUCH ------------------------------------------------------------ */ -/* Stats-Leiste */ -.diary-stats-bar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0; - padding: 8px 12px; - border-bottom: 1px solid var(--c-divider, var(--c-border)); - background: var(--c-surface); - flex-shrink: 0; -} -.diary-stats-numbers { - display: flex; - gap: 0; - overflow-x: auto; - scrollbar-width: none; - flex: 1; - min-width: 0; -} -.diary-stats-numbers::-webkit-scrollbar { display: none; } -.diary-stat { - display: flex; - flex-direction: column; - align-items: center; - min-width: 0; - padding: 0 8px; - border-right: 1px solid var(--c-border); -} -.diary-stat:last-child { border-right: none; } -.diary-stat-num { - font-size: 18px; - font-weight: 700; - color: var(--c-text); - line-height: 1.2; - white-space: nowrap; -} -.diary-stat-label { - font-size: 9px; - color: var(--c-text-muted); - margin-top: 2px; - white-space: nowrap; - text-transform: uppercase; - letter-spacing: .04em; -} - -/* View-Switcher */ -.diary-view-switcher { - display: flex; - gap: 0; - flex-shrink: 0; - margin-left: 8px; -} -.diary-view-btn { - background: none; - border: none; - cursor: pointer; - padding: 5px 6px; - border-radius: 8px; - color: var(--c-text-secondary); - display: flex; - align-items: center; - transition: background .15s, color .15s; -} -.diary-view-btn:hover { background: var(--c-surface-2); color: var(--c-text); } -.diary-view-btn.active { color: var(--c-primary); background: var(--c-primary-subtle); } -.diary-view-btn .ph-icon { width: 18px; height: 18px; } -@media (min-width: 640px) { - .diary-stat { padding: 0 12px; } - .diary-stat-num { font-size: 20px; } - .diary-view-btn { padding: 6px 8px; } - .diary-view-btn .ph-icon { width: 20px; height: 20px; } -} - -/* Meta-Zeile in der Karte */ -.diary-meta-loc { - display: inline-flex; - align-items: center; - gap: 2px; - max-width: 140px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.diary-meta-dot { color: var(--c-text-muted); opacity: .5; } - -/* Medien-Mosaic */ -.diary-media-mosaic { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 2px; - padding: 2px; -} -.diary-mosaic-item { - aspect-ratio: 1; - overflow: hidden; - cursor: pointer; - position: relative; -} -.diary-mosaic-item img { - width: 100%; height: 100%; - object-fit: cover; - display: block; - transition: opacity .2s; -} -.diary-mosaic-item:hover img { opacity: .85; } - -/* Kalender-Ansicht */ -.diary-calendar { padding: 0 0 80px; } -.diary-cal-nav { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 16px 8px; -} -.diary-cal-nav button { - background: none; border: none; cursor: pointer; - padding: 6px; border-radius: 8px; color: var(--c-text-muted); - display: flex; align-items: center; -} -.diary-cal-nav button:hover { background: var(--c-surface-2); } -.diary-cal-nav button .ph-icon { width: 20px; height: 20px; } -.diary-cal-month { font-size: 16px; font-weight: 600; color: var(--c-text); } -.diary-cal-weekdays { - display: grid; - grid-template-columns: repeat(7, 1fr); - padding: 0 8px 4px; - text-align: center; - font-size: 11px; - color: var(--c-text-muted); - font-weight: 500; -} -.diary-cal-grid { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 3px; - padding: 0 8px; -} -.diary-cal-cell { - aspect-ratio: 1; - border-radius: 8px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 3px; - position: relative; - overflow: hidden; - font-size: 13px; - color: var(--c-text-secondary); -} -.diary-cal-cell.has-entry { - cursor: pointer; - color: var(--c-text); - font-weight: 600; -} -.diary-cal-cell.has-entry:active { opacity: .7; } -/* Oranger Punkt unter der Tageszahl — sichtbar auch ohne Foto */ -.diary-cal-cell.has-entry::after { - content: ''; - display: block; - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--c-primary); - flex-shrink: 0; -} -/* Foto als Hintergrund */ -.diary-cal-cell.has-entry img { - position: absolute; inset: 0; - width: 100%; height: 100%; - object-fit: cover; - opacity: .4; - border-radius: 8px; -} -.diary-cal-cell.has-entry:hover img, -.diary-cal-cell.has-entry:active img { opacity: .6; } -/* Punkt ausblenden wenn Foto vorhanden (Foto reicht als Indikator) */ -.diary-cal-cell.has-entry:has(img)::after { display: none; } -.diary-cal-cell.today .diary-cal-day { - background: var(--c-primary); - color: var(--c-text-inverse); - border-radius: 50%; - width: 26px; height: 26px; - display: flex; align-items: center; justify-content: center; - font-weight: 700; -} -.diary-cal-day { position: relative; z-index: 1; font-size: 13px; } - -/* Monats-Section */ +/* Monats-Trennlinie */ .diary-month-header { - font-size: 22px; - font-weight: 700; - color: var(--c-text); - padding: 12px 16px; - background: var(--c-surface-2, #f5f5f5); - margin: 0; - border-top: 1px solid var(--c-border); - border-bottom: none; - letter-spacing: -0.01em; + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + color: var(--c-text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; + padding: var(--space-4) 0 var(--space-2); + border-bottom: 1px solid var(--c-border); + margin-bottom: var(--space-3); } .diary-month-header:first-child { - border-top: none; - margin-top: 0; -} -@media (prefers-color-scheme: dark) { - .diary-month-header { background: var(--c-surface-2); } -} -[data-theme="dark"] .diary-month-header { background: var(--c-surface-2); } - -/* Monats-Eintrags-Container (umschließt alle Karten einer Section) */ -.diary-month-entries { - background: var(--c-surface); - border-bottom: 1px solid var(--c-border); + padding-top: 0; } -/* Eintragskarte — Day One Row-Style */ +/* Eintragskarte */ .diary-card { - display: flex; - align-items: flex-start; - gap: 12px; - padding: 14px 16px; - background: transparent; - border: none; - border-bottom: 1px solid var(--c-divider, var(--c-border)); - border-radius: 0; - margin-bottom: 0; - overflow: visible; - cursor: pointer; - transition: background var(--transition-fast); - box-shadow: none; + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + margin-bottom: var(--space-3); + overflow: hidden; + cursor: pointer; + transition: box-shadow var(--transition-fast), + transform var(--transition-fast); + box-shadow: var(--shadow-xs); -webkit-tap-highlight-color: transparent; } -.diary-card:last-child { - border-bottom: none; -} .diary-card:hover { - background: rgba(0,0,0,0.025); - box-shadow: none; - transform: none; + box-shadow: var(--shadow-md); + transform: translateY(-1px); } .diary-card:active { - background: rgba(0,0,0,0.05); - transform: none; -} -[data-theme="dark"] .diary-card:hover { background: rgba(255,255,255,0.04); } -[data-theme="dark"] .diary-card:active { background: rgba(255,255,255,0.07); } - -/* Datum-Spalte links */ -.diary-card-date-col { - display: flex; - flex-direction: column; - align-items: center; - width: 44px; - flex-shrink: 0; - padding-top: 1px; -} -.diary-card-weekday { - font-size: 10px; - font-weight: 600; - color: var(--c-text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; - line-height: 1; - margin-bottom: 2px; -} -.diary-card-daynum { - font-size: 28px; - font-weight: 700; - color: var(--c-text); - line-height: 1; -} - -/* Meilenstein-Icon auf der Datum-Spalte */ -.diary-card-date-col .diary-milestone-icon { - font-size: 14px; - color: #c4a000; - margin-top: 4px; + transform: scale(0.99); } /* Meilenstein-Hervorhebung */ .diary-card--milestone { - background: color-mix(in srgb, #d4a017 4%, transparent); -} -.diary-card--milestone .diary-card-daynum { - color: #b8860b; + border-color: #d4a017; + border-width: 2px; + background: linear-gradient( + 135deg, + var(--c-surface) 0%, + color-mix(in srgb, #d4a017 8%, var(--c-surface)) 100% + ); } /* Meilenstein-Badge innerhalb der Karte */ .diary-card-milestone-badge { - display: inline-flex; - align-items: center; - gap: 4px; - background: color-mix(in srgb, #d4a017 15%, transparent); - color: #8a6400; - font-weight: 600; - font-size: var(--text-xs); - padding: 2px var(--space-2); - border-radius: var(--radius-full); - margin-bottom: 4px; + display: inline-flex; + align-items: center; + gap: 4px; + background: color-mix(in srgb, #d4a017 15%, transparent); + color: #8a6400; + font-weight: 600; + font-size: var(--text-xs); + padding: 2px var(--space-2); + border-radius: var(--radius-full); + margin-bottom: var(--space-2); letter-spacing: 0.03em; } -/* Foto / Thumbnail rechts — 72×72px */ +/* Foto / Video oben */ .diary-card-photo { - width: 72px; - height: 72px; - flex-shrink: 0; - border-radius: 8px; - overflow: hidden; - position: relative; - margin-top: 2px; + width: 100%; + height: 180px; + overflow: hidden; } .diary-card-photo img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; + width: 100%; + height: 100%; + object-fit: cover; + display: block; } .diary-media-picker { display: flex; @@ -1409,7 +1165,7 @@ html.modal-open { border-radius: 50%; border: none; background: rgba(0,0,0,.50); - color: rgba(255,255,255,.55); + color: #9ca3af; font-size: 14px; cursor: pointer; display: flex; @@ -1421,7 +1177,7 @@ html.modal-open { transition: color .15s, background .15s; } .diary-cover-btn--active { - color: var(--c-amber); + color: #f5c518; background: rgba(0,0,0,.65); } .diary-cover-btn--form { @@ -1429,46 +1185,48 @@ html.modal-open { left: var(--space-1); } -/* Card Body — mittlere Spalte */ +/* Card Body */ .diary-card-body { - flex: 1; - min-width: 0; - padding: 0; + padding: var(--space-3) var(--space-4); } -/* Titel in Karte */ -.diary-card-title { - font-size: 15px; - font-weight: 700; - color: var(--c-text); - margin: 0 0 3px; - line-height: 1.3; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -/* Meta-Zeile: nur noch für Compat — im neuen Design nicht als flex-row genutzt */ +/* Meta-Zeile: Typ + Datum */ .diary-card-meta { - display: none; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-1); +} +.diary-card-type { + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--c-primary); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.diary-card-date { + font-size: var(--text-xs); + color: var(--c-text-secondary); +} + +/* Titel */ +.diary-card-title { + font-size: var(--text-base); + font-weight: var(--weight-semibold); + color: var(--c-text); + margin-bottom: var(--space-1); } -.diary-card-type { display: none; } -.diary-card-date { display: none; } /* Ort-Zeile in Karte */ .diary-card-location { display: flex; align-items: center; - gap: 4px; - font-size: 12px; - color: var(--c-text-muted); - margin: 0 0 2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + gap: var(--space-1); + font-size: var(--text-sm); + color: var(--c-primary); + margin: 0 0 var(--space-1); } -.diary-card-location .ph-icon { flex-shrink: 0; width: 12px; height: 12px; } +.diary-card-location .ph-icon { flex-shrink: 0; } /* Ort in Detail-Ansicht */ .diary-detail-location { @@ -1534,12 +1292,12 @@ html.modal-open { /* Text-Vorschau */ .diary-card-text { - font-size: 13px; + font-size: var(--text-sm); color: var(--c-text-secondary); - line-height: 1.45; - margin: 0 0 4px; + line-height: 1.5; + margin: 0 0 var(--space-2); display: -webkit-box; - -webkit-line-clamp: 2; + -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } @@ -1552,62 +1310,6 @@ html.modal-open { margin-top: var(--space-1); } -/* Meta-Zeile unten in der Karte: Zeit · Ort · Wetter */ -.diary-card-meta-row { - font-size: 12px; - color: var(--c-text-muted); - line-height: 1.4; - margin-top: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Wetter-Badge in Karten-Meta */ -.diary-weather-badge { - display: inline-flex; - align-items: center; - gap: 2px; - font-size: var(--text-xs); - color: var(--c-text-secondary); - white-space: nowrap; -} - -/* FAB — Floating Action Button */ -.diary-fab { - position: fixed; - bottom: calc(var(--nav-bottom-height, 64px) + env(safe-area-inset-bottom, 0px) + 16px); - right: 20px; - width: 56px; - height: 56px; - border-radius: 50%; - background: var(--c-primary); - color: #fff; - border: none; - cursor: pointer; - box-shadow: 0 4px 16px rgba(196,132,58,.4); - display: flex; - align-items: center; - justify-content: center; - z-index: 100; - transition: transform .15s, box-shadow .15s; - -webkit-tap-highlight-color: transparent; -} -.diary-fab:hover { transform: scale(1.06); box-shadow: 0 6px 20px rgba(196,132,58,.5); } -.diary-fab:active { transform: scale(0.94); } - -/* POI-Chips in Karte und Detail */ -.diary-poi-chips, -.diary-detail-poi-chips { - font-size: var(--text-xs); - color: var(--c-text-muted); - line-height: 1.5; - margin: var(--space-1) 0 var(--space-1); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - /* Detail-Ansicht */ .diary-detail-milestone-badge { display: inline-flex; @@ -1622,312 +1324,6 @@ html.modal-open { margin-bottom: var(--space-3); } -/* Detail-View: Hero-Bild */ -.diary-detail-hero { - width: 100%; - max-height: 80vh; - background: #000; - flex-shrink: 0; - position: relative; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; -} -@media (min-width: 768px) { - .diary-detail-hero { - max-width: 1100px; - margin: 0 auto; - border-radius: 0 0 12px 12px; - max-height: 60vh; - } -} -@media (min-width: 1200px) { - .diary-detail-hero { max-width: 1300px; } -} -.diary-detail-hero img { - width: 100%; - height: auto; - max-height: 80vh; - object-fit: contain; - display: block; - cursor: zoom-in; -} -.diary-detail-hero video { - width: 100%; - height: auto; - max-height: 80vh; - object-fit: contain; - display: block; - background: #000; -} - -/* Detail-View: inline im Content-Bereich (kein Overlay mehr) */ -.diary-detail-view-inner { - display: flex; - flex-direction: column; - min-height: calc(100vh - 120px); - background: var(--c-bg); -} - -/* Detail-View: Header-Bar */ -.diary-detail-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - background: var(--c-surface); - border-bottom: 1px solid var(--c-border); - flex-shrink: 0; - min-height: 48px; -} -.diary-detail-back { - display: flex; - align-items: center; - gap: 6px; - background: none; - border: none; - color: var(--c-primary); - font-size: 16px; - cursor: pointer; - padding: 4px 0; - font-weight: 500; -} -.diary-detail-date-center { - font-size: 14px; - font-weight: 600; - color: var(--c-text); - text-align: center; - flex: 1; - padding: 0 8px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.diary-detail-edit { - background: none; - border: none; - color: var(--c-primary); - cursor: pointer; - padding: 4px 0; - display: flex; - align-items: center; - gap: 4px; - font-size: 14px; - font-weight: 500; -} - -/* Detail-View: Body-Wrapper (text links, Karte rechts auf Desktop) */ -.diary-detail-body-wrap { - display: flex; - flex-direction: column; - flex: 1; - width: 100%; -} -@media (min-width: 768px) { - .diary-detail-body-wrap { - flex-direction: row; - align-items: flex-start; - max-width: 1100px; - margin: 0 auto; - padding: 0 24px; - box-sizing: border-box; - } -} -@media (min-width: 1200px) { - .diary-detail-body-wrap { max-width: 1300px; } -} - -/* Detail-View: Inhalt */ -.diary-detail-content { - padding: 24px 24px 60px; - flex: 1; - min-width: 0; -} -@media (max-width: 767px) { - .diary-detail-content { padding: 20px 16px 40px; } -} - -/* Detail-View: Karte + POI-Sektion */ -.diary-detail-map-wrap { - padding: 16px 16px 40px; - flex-shrink: 0; - width: 100%; -} -@media (min-width: 768px) { - .diary-detail-map-wrap { - width: 380px; - min-width: 300px; - max-width: 420px; - flex-shrink: 0; - padding: 24px 0 40px 32px; - position: sticky; - top: 60px; - align-self: flex-start; - } -} -.diary-detail-map { - width: 100%; - height: 200px; - border-radius: 12px; - overflow: hidden; - border: 1px solid var(--c-border); - margin-bottom: 12px; -} -@media (min-width: 768px) { - .diary-detail-map { height: 280px; } -} - -/* POI-Liste */ -.diary-detail-poi-list { - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: 12px; - overflow: hidden; -} -.diary-detail-poi-heading { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: .06em; - color: var(--c-text-muted); - padding: 10px 14px 8px; - border-bottom: 1px solid var(--c-border); -} -.diary-detail-poi-heading .ph-icon { width:14px;height:14px; } -.diary-detail-poi-row { - display: flex; - align-items: center; - gap: 10px; - padding: 9px 14px; - border-bottom: 1px solid var(--c-border); - font-size: 13px; -} -.diary-detail-poi-row:last-child { border-bottom: none; } -.diary-detail-poi-icon { width:16px;height:16px;color:var(--c-primary);flex-shrink:0; } -.diary-detail-poi-name { flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c-text); } -.diary-detail-poi-dist { font-size:12px;color:var(--c-text-muted);flex-shrink:0; } -.diary-detail-title { - font-size: 22px; - font-weight: 700; - color: var(--c-text); - margin: 0 0 16px; - line-height: 1.3; -} -.diary-detail-body { - font-size: 16px; - line-height: 1.7; - color: var(--c-text); - white-space: pre-wrap; - margin: 0 0 20px; -} -.diary-detail-divider { - border: none; - border-top: 1px solid var(--c-border); - margin: 20px 0; -} - -/* Detail-View: Meta-Bar unten */ -.diary-detail-meta-bar { - display: flex; - flex-wrap: wrap; - gap: 8px 16px; - font-size: 13px; - color: var(--c-text-muted); - margin-bottom: 16px; - align-items: center; -} -.diary-detail-meta-bar .ph-icon { - width: 14px; - height: 14px; - flex-shrink: 0; -} -.diary-detail-meta-item { - display: flex; - align-items: center; - gap: 5px; -} - -/* Detail-View: Thumbnail-Strip */ -.diary-detail-thumbs { - display: flex; - gap: 4px; - padding: 6px 16px; - overflow-x: auto; - background: rgba(0,0,0,.6); - flex-shrink: 0; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; -} -@media (min-width: 768px) { - .diary-detail-thumbs { - max-width: 1100px; - margin: 0 auto; - border-radius: 0 0 8px 8px; - padding-left: 16px; - padding-right: 16px; - background: rgba(0,0,0,.75); - width: 100%; - box-sizing: border-box; - } -} -@media (min-width: 1200px) { - .diary-detail-thumbs { max-width: 1300px; } -} -.diary-detail-thumbs::-webkit-scrollbar { display: none; } -.diary-detail-thumb { - flex-shrink: 0; - width: 56px; - height: 56px; - border-radius: 6px; - overflow: hidden; - cursor: pointer; - border: 2px solid transparent; - box-sizing: border-box; - transition: border-color .15s, opacity .15s; - opacity: .7; -} -.diary-detail-thumb:hover { opacity: 1; } -.diary-detail-thumb--active { - border-color: var(--c-primary); - opacity: 1; -} -@media (min-width: 768px) { - .diary-detail-thumb { width: 72px; height: 72px; } -} - -/* Detail-View: Foto-Galerie horizontal */ -.diary-detail-gallery { - display: flex; - gap: 8px; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - scroll-snap-type: x mandatory; - margin: 0 -20px 20px; - padding: 0 20px; - scrollbar-width: none; -} -.diary-detail-gallery::-webkit-scrollbar { display: none; } -.diary-detail-gallery-item { - flex: 0 0 auto; - width: min(75vw, 280px); - height: 200px; - border-radius: var(--radius-md); - overflow: hidden; - scroll-snap-align: start; - cursor: zoom-in; -} -.diary-detail-gallery-item img, -.diary-detail-gallery-item video { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - /* Leaflet-Attribution ausblenden */ .leaflet-control-attribution { display: none !important; } diff --git a/backend/static/css/design-system.css b/backend/static/css/design-system.css index 5b6f1e0..dc1a0b2 100644 --- a/backend/static/css/design-system.css +++ b/backend/static/css/design-system.css @@ -44,7 +44,6 @@ --c-warning: #D4923A; --c-warning-subtle: #FDF3E3; --c-amber: #E4A020; /* Goldgelb — "Heute"-Akzent, distinct von Primary */ - --c-icon: #7A6A58; /* Standard-Icon-Farbe (= text-secondary im Light-Mode) */ --c-info: #4A7A9B; --c-info-subtle: #E8F2F8; @@ -138,11 +137,8 @@ --c-text: #F0EAE0; --c-text-secondary: #C0B0A0; - --c-text-muted: #9A8878; + --c-text-muted: #806A58; --c-text-inverse: #2A1F14; - --c-icon: #B0A090; - --c-amber: #C48820; - --c-success: #6A9E58; --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30); --shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.25); diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css index 65b62e3..ed396fb 100644 --- a/backend/static/css/layout.css +++ b/backend/static/css/layout.css @@ -202,7 +202,7 @@ justify-content: center; gap: 3px; cursor: pointer; - color: var(--c-icon, var(--c-text-secondary)); + color: var(--c-text-muted); transition: color var(--transition-fast); -webkit-tap-highlight-color: transparent; touch-action: manipulation; diff --git a/backend/static/icons/phosphor.svg b/backend/static/icons/phosphor.svg index 87a2ec2..7512a15 100644 --- a/backend/static/icons/phosphor.svg +++ b/backend/static/icons/phosphor.svg @@ -30,21 +30,6 @@ - - - - - - - - - - - - - - - diff --git a/backend/static/index.html b/backend/static/index.html index 8222594..85f466e 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -88,9 +88,9 @@ - - - + + + @@ -124,9 +124,6 @@ - Entdecken - -
-
-
+
- - - - -
- `; - bar.style.display = 'flex'; - - bar.querySelectorAll('.diary-view-btn').forEach(btn => { - btn.addEventListener('click', () => { - _currentView = btn.dataset.view; - _renderStatsBar(); - _renderCurrentView(); - }); - }); - } - - function _renderCurrentView() { - const content = _container.querySelector('#diary-view-content'); - const loadMore = _container.querySelector('#diary-load-more'); - if (!content) return; - // "Weitere laden" nur in der Listenansicht sinnvoll - if (loadMore) loadMore.style.display = 'none'; - if (_currentView === 'list') { - content.innerHTML = '
'; - _renderList(); - // Sichtbarkeit des "Weitere laden"-Buttons nach _load() steuern (bereits in _load()) - } else if (_currentView === 'media') { - _renderMediaGrid(content); - } else if (_currentView === 'calendar') { - _renderCalendarView(content); - } else if (_currentView === 'map') { - _renderMapView(content); - } - } - - async function _renderMapView(content) { - const dog = _appState.activeDog; - if (!dog) return; - - content.innerHTML = `
-
- - Karte wird geladen… -
-
`; - - let locations; - try { - locations = await API.diary.locations(dog.id); - } catch (e) { - content.innerHTML = `

Standorte konnten nicht geladen werden.

`; - return; - } - - if (!locations.length) { - content.innerHTML = UI.emptyState({ icon: UI.icon('map-pin'), title: 'Keine Standorte', text: 'Füge GPS-Koordinaten zu Tagebucheinträgen hinzu.' }); - return; - } - - // Leaflet laden - if (!window.L) { - await new Promise((res, rej) => { - const s = document.createElement('script'); - s.src = `/js/leaflet.js?v=${APP_VER}`; - s.onload = res; s.onerror = rej; - document.head.appendChild(s); - }); - } - - const mapEl = content.querySelector('#diary-map-view'); - if (!mapEl) return; - - // Bounds aus allen Punkten berechnen - const lats = locations.map(l => l.gps_lat); - const lons = locations.map(l => l.gps_lon); - const bounds = [[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]]; - - const map = L.map(mapEl, { zoomControl: true, attributionControl: false }); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map); - - // Marker für jeden Eintrag - locations.forEach(loc => { - const hasPhoto = !!loc.cover_url; - const dateStr = loc.datum ? new Date(loc.datum+'T12:00').toLocaleDateString('de-DE', {day:'numeric',month:'short',year:'numeric'}) : ''; - const title = UI.escape(loc.titel || loc.location_name || dateStr); - - const icon = L.divIcon({ - html: hasPhoto - ? `
- -
` - : `
- -
`, - iconSize: hasPhoto ? [44, 44] : [32, 32], - iconAnchor: hasPhoto ? [22, 22] : [16, 16], - className: '', - }); - - const marker = L.marker([loc.gps_lat, loc.gps_lon], { icon }); - marker.bindPopup(` -
- ${hasPhoto ? `` : ''} -
${title}
-
${dateStr}
- ${loc.media_count > 1 ? `
📷 ${loc.media_count} Medien
` : ''} -
→ Öffnen
-
`, { maxWidth: 200 }); - - marker.on('popupopen', () => { - setTimeout(() => { - document.querySelectorAll('.diary-map-popup').forEach(el => { - el.addEventListener('click', async () => { - map.closePopup(); - const id = parseInt(el.dataset.id); - // Eintrag aus _entries holen oder per API nachladen - if (!_entries.find(e => e.id === id)) { - try { - const fresh = await API.diary.get(_appState.activeDog.id, id); - _entries.unshift(fresh); - } catch { return; } - } - _openDetail(id); - }); - }); - }, 50); - }); - - marker.addTo(map); - }); - - // Karte auf alle Punkte zoomen - if (locations.length === 1) { - map.setView([locations[0].gps_lat, locations[0].gps_lon], 14); - } else { - map.fitBounds(bounds, { padding: [40, 40] }); - } - - setTimeout(() => map.invalidateSize(), 100); - } - - function _renderMediaGrid(content) { - const allMedia = []; - _entries.forEach(e => { - _allMedia(e).forEach(m => { - if (m.media_type === 'image') allMedia.push({ url: m.url, entryId: e.id, datum: e.datum }); - }); - }); - if (allMedia.length === 0) { - content.innerHTML = UI.emptyState({ icon: UI.icon('images'), title: 'Keine Medien', text: 'Füge Fotos oder Videos zu Tagebucheinträgen hinzu.' }); - return; - } - content.innerHTML = `
${ - allMedia.map(m => ` -
- -
`).join('') - }
`; - content.querySelectorAll('.diary-mosaic-item').forEach(el => { - el.addEventListener('click', () => _openDetail(parseInt(el.dataset.entryId))); - }); - } - - async function _renderCalendarView(content) { - const dog = _appState.activeDog; - if (!dog) return; - - const today = new Date().toISOString().slice(0, 10); - const now = new Date(); - let year = now.getFullYear(), month = now.getMonth(); - - content.innerHTML = `
- - Kalender wird geladen…
`; - - let byDate = {}; - try { - const all = await API.diary.calendar(dog.id); - if (!Array.isArray(all)) throw new Error('Keine Array-Antwort: ' + typeof all); - all.forEach(e => { if (e && e.datum) byDate[e.datum] = e; }); - } catch (err) { - content.innerHTML = `

Kalender-Fehler: ${UI.escape(String(err))}

`; - return; - } - // Debug: Anzahl geladener Einträge kurz anzeigen - const _total = Object.keys(byDate).length; - if (_total === 0) { - content.innerHTML = `

Keine Einträge mit Datum gefunden.

`; - return; - } - - const render = () => { - const firstDay = new Date(year, month, 1).getDay(); - const daysInMonth = new Date(year, month + 1, 0).getDate(); - const monthName = new Date(year, month).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); - const monthPrefix = `${year}-${String(month+1).padStart(2,'0')}`; - const monthCount = Object.keys(byDate).filter(k => k.startsWith(monthPrefix)).length; - const DAYS = ['Mo','Di','Mi','Do','Fr','Sa','So']; - const offset = firstDay === 0 ? 6 : firstDay - 1; - const cells = []; - for (let i = 0; i < offset; i++) cells.push('
'); - for (let d = 1; d <= daysInMonth; d++) { - const key = `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; - const entry = byDate[key]; - cells.push(`
- ${entry?.cover_url ? `` : ''} - ${d} -
`); - } - - // Nächsten/vorherigen Monat MIT Einträgen finden für Sprungbuttons - const allMonths = [...new Set(Object.keys(byDate).map(k => k.slice(0,7)))].sort(); - const curM = monthPrefix; - const prevM = allMonths.filter(m => m < curM).at(-1) || null; - const nextM = allMonths.filter(m => m > curM)[0] || null; - - content.innerHTML = ` -
-
-
- ${prevM ? `` : '
'} - -
- ${monthName}${monthCount > 0 ? `${monthCount}` : ''} -
- - ${nextM ? `` : '
'} -
-
-
${DAYS.map(n=>`
${n}
`).join('')}
-
${cells.join('')}
-
`; - }; - - // Event-Delegation auf content — überlebt innerHTML-Erneuerungen - content.addEventListener('click', async e => { - const navBtn = e.target.closest('.cal-nav-btn'); - if (navBtn) { - // Sprungbutton: direkt zu Monat mit Einträgen - if (navBtn.dataset.jump) { - const [y, m] = navBtn.dataset.jump.split('-').map(Number); - year = y; month = m - 1; - render(); - return; - } - const dir = parseInt(navBtn.dataset.dir); - month += dir; - if (month < 0) { month = 11; year--; } - if (month > 11) { month = 0; year++; } - render(); - return; - } - const cell = e.target.closest('.diary-cal-cell.has-entry'); - if (cell) { - const id = parseInt(cell.dataset.entryId); - if (!id) return; - if (!_entries.find(en => en.id === id)) { - try { - const fresh = await API.diary.get(dog.id, id); - _entries.unshift(fresh); - } catch { return; } - } - _openDetail(id); - } - }); - - render(); - } - async function _loadMore() { _offset += LIMIT; const btn = _container.querySelector('#diary-btn-more'); @@ -730,7 +327,7 @@ window.Page_diary = (() => { } // ---------------------------------------------------------- - // LISTE RENDERN — Timeline gruppiert nach Monat (Day One Style) + // LISTE RENDERN — Timeline gruppiert nach Monat // ---------------------------------------------------------- function _renderList() { const listEl = _container.querySelector('#diary-list'); @@ -760,9 +357,7 @@ window.Page_diary = (() => { groups.forEach((items, key) => { const monthLabel = key === 'unbekannt' ? 'Datum unbekannt' : _formatMonth(key); html += `
${monthLabel}
`; - html += `
`; html += items.map(e => _entryCard(e)).join(''); - html += `
`; }); listEl.innerHTML = html; @@ -772,101 +367,64 @@ window.Page_diary = (() => { const id = parseInt(card.dataset.entryId); card.addEventListener('click', () => _openDetail(id)); }); - listEl.querySelectorAll('[data-action="open-note"]').forEach(btn => { - btn.addEventListener('click', e => { - e.stopPropagation(); - const id = parseInt(btn.dataset.entryId); - const label = btn.dataset.label || ''; - const location = btn.dataset.location || null; - _openNoteModal('diary', id, label, location || null); - }); - }); } // ---------------------------------------------------------- - // ENTRY CARD — Day One Row-Style + // ENTRY CARD // ---------------------------------------------------------- function _entryCard(e) { + const typ = TYPEN[e.typ] || TYPEN.eintrag; const isMile = e.is_milestone || e.typ === 'meilenstein'; + const dateStr = e.datum ? UI.time.format(e.datum + 'T00:00:00') : ''; + const tags = (e.tags || []).filter(t => t && t.trim()).slice(0, 4); const allMedia = _allMedia(e); const coverMedia = allMedia.find(m => m.is_cover) || allMedia[0] || null; const mediaCount = allMedia.length; - - // Thumbnail rechts (72×72) - let photoHtml = ''; - if (coverMedia) { - if (coverMedia.media_type === 'video') { - photoHtml = `
-
- -
- ${mediaCount > 1 ? `${mediaCount}` : ''} -
`; - } else { - photoHtml = `
- Foto - ${mediaCount > 1 ? `${mediaCount}` : ''} -
`; - } - } - - // Vorschautext (max 2 Zeilen via CSS clamp) - const cleanedText = e.text ? _cleanText(e.text) : ''; - const textPreview = cleanedText - ? `

${UI.escape(cleanedText.slice(0, 160))}

` + const photo = coverMedia + ? `
+ ${coverMedia.media_type === 'video' + ? `
` + : `Foto`} + ${mediaCount > 1 ? `${mediaCount}` : ''} +
` : ''; - // Meta-Zeile: Zeit · 📍 Ort · Wetter - const metaParts = []; - if (e.created_at) { - const t = _timeStr(e.created_at); - if (t) metaParts.push(`${t}`); - } - if (e.location_name) { - metaParts.push(`${UI.escape(e.location_name)}`); - } - if (e.weather_json) { - try { - const w = typeof e.weather_json === 'string' ? JSON.parse(e.weather_json) : e.weather_json; - const temp = w?.temperature_2m ?? w?.temp_c; - if (temp != null) { - metaParts.push(`${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°`); - } - } catch (_) {} - } - const metaRow = metaParts.length - ? `
${metaParts.join(' · ')}
` + const tagsHtml = tags.length + ? `
${tags.map(t => `${t}`).join('')}
` : ''; - // Meilenstein-Icon auf der Datum-Spalte - const mileIcon = isMile - ? `` + const locationHtml = e.location_name + ? `

${UI.escape(e.location_name)}

` : ''; - // Titel oder Typ als Fallback - const typObj = TYPEN[e.typ] || TYPEN.eintrag; - const titleText = e.titel - ? `
${UI.escape(e.titel)}
` - : `
${typObj.label}
`; + const textPreview = e.text + ? `

${UI.escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}

` + : ''; + + // Meilenstein-Badge (nur bei is_milestone=1, nicht bei manuell gewähltem Typ 'meilenstein') + const milestoneBadge = e.is_milestone + ? `
${UI.icon('calendar-dots')} Meilenstein
` + : ''; + + // Mehrere Hunde: kleine Avatare in der Karte + const dogAvatars = _dogAvatarRow(e.dog_ids || []); - const noteLabel = e.titel || e.datum || ''; return `
- -
- ${_weekday(e.datum)} - ${_dayNum(e.datum)} - ${mileIcon} -
- + ${photo}
- ${titleText} + ${milestoneBadge} +
+ ${typ.icon} ${typ.label} + ${dateStr} +
+ ${e.titel ? `
${UI.escape(e.titel)}
` : ''} + ${locationHtml} ${textPreview} - ${metaRow} + ${tagsHtml} + ${dogAvatars}
- - ${photoHtml}
`; } @@ -899,38 +457,28 @@ window.Page_diary = (() => { const render = () => { lb.innerHTML = ` +
+ + ${photos.length > 1 + ? `${idx+1} / ${photos.length}` + : ''} +
+
-
- -
- - - ${photos.length > 1 ? `${idx+1} / ${photos.length}` : ''} - ${photos.length > 1 ? ` -
- - -
` : '
'} + ` : ''}
`; lb.querySelector('#lb-close').addEventListener('click', () => lb.remove()); @@ -942,7 +490,7 @@ window.Page_diary = (() => { } // ---------------------------------------------------------- - // DETAIL-ANSICHT — Fullscreen Day One-Stil + // DETAIL-ANSICHT — Fullscreen (DayOne-Stil) // ---------------------------------------------------------- function _openDetail(entryId) { const entry = _entries.find(e => e.id === entryId); @@ -954,7 +502,6 @@ window.Page_diary = (() => { const allMedia = _allMedia(entry); const dogIds = entry.dog_ids || [entry.dog_id]; - // Hunde-Chips (bei mehreren Hunden) const dogsHtml = dogIds.length > 1 ? `
${dogIds.map(did => { @@ -966,217 +513,96 @@ window.Page_diary = (() => { }).join('')}
` : ''; - // Detail-View im Content-Bereich rendern (gleiche Breite wie Liste/Kalender) - const content = _container.querySelector('#diary-view-content'); - if (!content) return; - // FAB + "Weitere laden" ausblenden während Detail offen - _container.querySelector('#diary-fab')?.style.setProperty('display','none'); - const _lm = _container.querySelector('#diary-load-more'); - if (_lm) _lm.style.display = 'none'; - const view = document.createElement('div'); view.id = 'diary-detail-view'; - view.className = 'diary-detail-view-inner'; + view.style.cssText = 'position:fixed;inset:0;z-index:800;background:var(--c-bg);overflow-y:auto;display:flex;flex-direction:column'; - // Medien-HTML für Hero-Bereich (45vh) - const _heroHtml = (m) => { - if (m.media_type === 'pdf') { - return ` m.media_type === 'pdf' + ? ` - + gap:12px;padding:32px 16px;background:var(--c-surface-2);text-decoration:none;color:var(--c-text)"> + ${UI.escape(m.url.split('/').pop())} PDF öffnen - `; - } - if (m.media_type === 'video') { - return ``; - } - return ``; - }; + ` + : m.media_type === 'video' + ? `` + : ``; - // Hero-Sektion - let heroSection = ''; - if (allMedia.length >= 1) { - const thumbsHtml = allMedia.length > 1 - ? `
- ${allMedia.map((m, i) => ` -
- ${m.media_type === 'pdf' - ? `
` - : m.media_type === 'video' - ? `` - : ``} -
`).join('')} -
` - : ''; - heroSection = ` -
${_heroHtml(allMedia[0])}
- ${thumbsHtml}`; + let mediaSection = ''; + if (allMedia.length === 1) { + mediaSection = `
${_heroHtml(allMedia[0])}
`; + } else if (allMedia.length > 1) { + mediaSection = ` +
${_heroHtml(allMedia[0])}
+
+ ${allMedia.map((m, i) => ` +
+ ${m.media_type === 'pdf' + ? `
` + : m.media_type === 'video' + ? `` + : ``} +
`).join('')} +
`; } - // Datum lang formatiert: "Montag, 21. April 2026" - const datumLang = _formatDateLong(entry.datum); - - // Meta-Bar: Zeit · Ort · Wetter (korrekte Feldnamen aus Open-Meteo) - const metaItems = []; - if (entry.created_at) { - const t = _timeStr(entry.created_at); - if (t) metaItems.push(`${t}`); - } - if (entry.location_name) { - const locContent = entry.gps_lat - ? `${UI.escape(entry.location_name)}` - : UI.escape(entry.location_name); - metaItems.push(`${locContent}`); - } - if (entry.weather_json) { - try { - const w = typeof entry.weather_json === 'string' ? JSON.parse(entry.weather_json) : entry.weather_json; - const temp = w?.temperature_2m ?? w?.temp_c; - if (w && temp != null) { - const feels = w.apparent_temperature ?? w.feels_like_c; - const wind = w.wind_speed_10m ?? w.wind_kmh; - const parts = [ - `${_weatherEmoji(w.weather_code ?? w.weathercode, w.is_day)} ${Math.round(temp)}°C`, - feels != null ? `gefühlt ${Math.round(feels)}°` : null, - wind != null ? `💨 ${Math.round(wind)} km/h` : null, - w.relative_humidity_2m != null ? `💧 ${w.relative_humidity_2m}%` : null, - ].filter(Boolean).join(' · '); - metaItems.push(`${parts}`); - } - } catch (_) {} - } - const metaBar = metaItems.length - ? `
${metaItems.join('')}
` - : ''; - - // Tags - const tagsSection = tags.length - ? `
- ${tags.map(t => `${t}`).join('')} -
` - : ''; - - // POI-Liste (wie Routen) - const POI_ICON = { restaurant:'fork-knife', cafe:'coffee', bar:'beer-bottle', - pharmacy:'first-aid', hospital:'first-aid', park:'tree', playground:'soccer-ball', - supermarket:'shopping-cart', shop:'shopping-bag', attraction:'star', - viewpoint:'binoculars', museum:'buildings', hotel:'bed', church:'church', - school:'graduation-cap', default:'map-pin' }; - let poiListHtml = ''; - if (entry.poi_json) { - try { - const pois = typeof entry.poi_json === 'string' ? JSON.parse(entry.poi_json) : entry.poi_json; - if (pois?.length) { - poiListHtml = `
-
- - In der Nähe -
- ${pois.map(p => { - const icon = POI_ICON[p.type] || POI_ICON.default; - const dist = p.distance_m < 1000 ? `${p.distance_m} m` : `${(p.distance_m/1000).toFixed(1)} km`; - return `
- - ${UI.escape(p.name)} - ${dist} -
`; - }).join('')} -
`; - } - } catch (_) {} - } - - // Karte (wenn GPS vorhanden) — Platzhalter-Div, wird nach DOM-Insert befüllt - const hasGps = entry.gps_lat != null && entry.gps_lon != null; - const mapSection = hasGps - ? `
-
- ${poiListHtml} -
` - : ''; - view.innerHTML = ` -
- - ${datumLang} ${!_appState?.activeDog?.is_guest - ? `` - : '
'} + ? `` + : '
'}
- ${heroSection} + ${mediaSection} -
-
- ${isMile ? `
Meilenstein
` : ''} - ${entry.titel ? `

${UI.escape(entry.titel)}

` : ''} - ${metaBar} - ${dogsHtml} - ${entry.text - ? `

${UI.escape(_cleanText(entry.text))}

` - : ''} - ${metaItems.length || entry.text ? '
' : ''} -
- ${typ.icon} ${typ.label} -
- ${tagsSection} +
+ ${isMile ? `
${UI.icon('trophy')} Meilenstein
` : ''} + ${entry.titel ? `

${UI.escape(entry.titel)}

` : ''} +
+ ${typ.icon} ${typ.label} + + ${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''} +
- ${mapSection} + ${entry.location_name ? ` +
+ + ${entry.gps_lat + ? `${UI.escape(entry.location_name)}` + : UI.escape(entry.location_name)} +
` : ''} + ${dogsHtml} + ${entry.text + ? `

${UI.escape(_cleanText(entry.text))}

` + : ''} + ${tags.length + ? `
+ ${tags.map(t => `${t}`).join('')} +
` + : ''}
`; - // In Content-Bereich einsetzen statt als Fixed-Overlay - content.innerHTML = ''; - content.appendChild(view); - UI.scrollTop(); // Seite nach oben scrollen + document.body.appendChild(view); - // Leaflet-Karte initialisieren (wenn GPS vorhanden) - if (hasGps) { - setTimeout(async () => { - const mapEl = view.querySelector('#diary-dv-map'); - if (!mapEl) return; - if (!window.L) { - await new Promise((res, rej) => { - const s = document.createElement('script'); - s.src = `/js/leaflet.js?v=${APP_VER}`; - s.onload = res; s.onerror = rej; - document.head.appendChild(s); - }); - } - const map = L.map(mapEl, { zoomControl: true, attributionControl: false }); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map); - const svgIcon = L.divIcon({ - html: ` - - - `, - iconSize: [32, 32], iconAnchor: [16, 16], className: '', - }); - L.marker([entry.gps_lat, entry.gps_lon], { icon: svgIcon }).addTo(map); - map.setView([entry.gps_lat, entry.gps_lon], 15); - map.invalidateSize(); - }, 150); - } - - // Zurück → vorherige Ansicht wiederherstellen - const _closeDetail = () => { - _container.querySelector('#diary-fab')?.style.removeProperty('display'); - _renderCurrentView(); - _renderStatsBar(); - }; - view.querySelector('#diary-dv-back').addEventListener('click', _closeDetail); + // Zurück + view.querySelector('#diary-dv-back').addEventListener('click', () => view.remove()); // Bearbeiten view.querySelector('#diary-dv-edit')?.addEventListener('click', async () => { - _container.querySelector('#diary-fab')?.style.removeProperty('display'); + view.remove(); if (entry.location_name !== undefined || entry.gps_lat !== undefined) { _showForm(entry); } else { @@ -1191,30 +617,35 @@ window.Page_diary = (() => { // Foto in Hero → Lightbox const photoUrls = allMedia.filter(m => m.media_type !== 'video').map(m => m.url); - view.querySelector('#diary-dv-hero')?.querySelector('img')?.addEventListener('click', ev => { - const clickedIdx = parseInt(ev.target.dataset.idx ?? 0); - const photoIdx = allMedia.slice(0, clickedIdx + 1).filter(m => m.media_type !== 'video').length - 1; + view.querySelector('#diary-dv-hero')?.querySelector('img')?.addEventListener('click', e => { + const clickedIdx = parseInt(e.target.dataset.idx ?? 0); + const photoIdx = allMedia.slice(0, clickedIdx+1).filter(m => m.media_type !== 'video').length - 1; _showLightbox(photoUrls, Math.max(0, photoIdx)); }); // Thumbnail-Strip → Hero wechseln - view.querySelector('#diary-dv-thumbs')?.addEventListener('click', ev => { - const thumb = ev.target.closest('[data-idx]'); + view.querySelector('#diary-dv-thumbs')?.addEventListener('click', e => { + const thumb = e.target.closest('[data-idx]'); if (!thumb) return; const i = parseInt(thumb.dataset.idx); const hero = view.querySelector('#diary-dv-hero'); if (hero) hero.innerHTML = _heroHtml(allMedia[i]); // Foto in neuem Hero → Lightbox - hero?.querySelector('img')?.addEventListener('click', ev2 => { - const clickedIdx = parseInt(ev2.target.dataset.idx ?? i); - const photoIdx = allMedia.slice(0, clickedIdx + 1).filter(m => m.media_type !== 'video').length - 1; + hero?.querySelector('img')?.addEventListener('click', ev => { + const clickedIdx = parseInt(ev.target.dataset.idx ?? i); + const photoIdx = allMedia.slice(0, clickedIdx+1).filter(m => m.media_type !== 'video').length - 1; _showLightbox(photoUrls, Math.max(0, photoIdx)); }); // Aktive Markierung - view.querySelectorAll('#diary-dv-thumbs .diary-detail-thumb').forEach((t, j) => { - t.classList.toggle('diary-detail-thumb--active', j === i); + view.querySelectorAll('#diary-dv-thumbs [data-idx]').forEach((t, j) => { + t.style.borderColor = j === i ? 'var(--c-primary)' : 'transparent'; }); }); + + // Cover-Button: Stern-Icon auf aktiven Medien (optional, nur für eingeloggte) + if (!_appState?.activeDog?.is_guest && allMedia.some(m => m.id)) { + // Cover-Verwaltung über Edit-Dialog + } } // ---------------------------------------------------------- @@ -1691,16 +1122,12 @@ window.Page_diary = (() => { async function _uploadNewFiles(entryId) { let failCount = 0; const uploaded = []; - let exifGps = null; for (const file of _newFiles) { try { const formData = new FormData(); formData.append('file', file); const m = await API.diary.uploadMedia(_appState.activeDog.id, entryId, formData); uploaded.push(m); - if (m.exif_lat != null && m.exif_lon != null && !exifGps) { - exifGps = { lat: m.exif_lat, lon: m.exif_lon }; - } } catch { failCount++; } @@ -1708,23 +1135,17 @@ window.Page_diary = (() => { if (failCount > 0) { UI.toast.warning(`${failCount} Medium${failCount > 1 ? 'en' : ''} konnte${failCount > 1 ? 'n' : ''} nicht hochgeladen werden.`); } - if (exifGps) { - UI.toast.success(`📍 Standort aus Foto-GPS übernommen`); - } - return { uploaded, exifGps }; + return uploaded; } if (isEdit) { const updated = await API.diary.update(_appState.activeDog.id, entry.id, payload); if (_newFiles.length > 0) { - const { uploaded, exifGps } = await _uploadNewFiles(entry.id); + const uploaded = await _uploadNewFiles(entry.id); if (!updated.media_items) updated.media_items = []; updated.media_items.push(...uploaded); - if (exifGps && !updated.gps_lat) { - updated.gps_lat = exifGps.lat; - updated.gps_lon = exifGps.lon; - } } else { + // media_items aus dem aktuellen entry-State übernehmen (evtl. gelöscht via X-Button) updated.media_items = entry.media_items || updated.media_items || []; updated.media_url = entry.media_url ?? updated.media_url; } @@ -1733,12 +1154,8 @@ window.Page_diary = (() => { } else { const created = await API.diary.create(_appState.activeDog.id, payload); if (_newFiles.length > 0) { - const { uploaded, exifGps } = await _uploadNewFiles(created.id); + const uploaded = await _uploadNewFiles(created.id); created.media_items = uploaded; - if (exifGps && !created.gps_lat) { - created.gps_lat = exifGps.lat; - created.gps_lon = exifGps.lon; - } } _entries.unshift(created); UI.toast.success('Eintrag erstellt.'); @@ -1746,7 +1163,6 @@ window.Page_diary = (() => { UI.modal.close(); _renderList(); - _loadStats().then(() => _renderStatsBar()); }); }); } @@ -1760,7 +1176,6 @@ window.Page_diary = (() => { _entries = _entries.filter(e => e.id !== entryId); UI.modal.close(); _renderList(); - _loadStats().then(() => _renderStatsBar()); UI.toast.success('Eintrag gelöscht.'); } catch (err) { UI.toast.error(err.message || 'Fehler beim Löschen.'); @@ -1900,87 +1315,6 @@ window.Page_diary = (() => { }); } - // ---------------------------------------------------------- - // NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden) - // ---------------------------------------------------------- - async function _openNoteModal(parentType, parentId, parentLabel, locationName) { - document.getElementById('by-note-modal')?.remove(); - - const overlay = document.createElement('div'); - overlay.id = 'by-note-modal'; - overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center'; - - overlay.innerHTML = ` -
-
-
-
Notiz
-
${UI.escape(parentLabel)}
-
- -
-
-
- -
-
-
- - -
-
- `; - - document.body.appendChild(overlay); - - const textarea = document.getElementById('by-note-text'); - const saveBtn = document.getElementById('by-note-save'); - const cancelBtn = document.getElementById('by-note-cancel'); - const closeBtn = document.getElementById('by-note-close'); - - let existingNoteId = null; - - try { - const existing = await API.notes.get(parentType, parentId); - if (existing?.id) { - existingNoteId = existing.id; - textarea.value = existing.text || ''; - } - } catch (_) { /* keine Notiz vorhanden — ok */ } - - setTimeout(() => textarea.focus(), 100); - - const _close = () => overlay.remove(); - closeBtn.addEventListener('click', _close); - cancelBtn.addEventListener('click', _close); - overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); - - document.getElementById('by-note-form').addEventListener('submit', async e => { - e.preventDefault(); - const text = textarea.value.trim(); - UI.setLoading(saveBtn, true); - try { - const payload = { text, parent_label: parentLabel, location_name: locationName }; - if (existingNoteId) { - await API.notes.update(existingNoteId, payload); - } else { - await API.notes.create(parentType, parentId, payload); - } - UI.toast.success('Notiz gespeichert.'); - _close(); - } catch (err) { - UI.toast.error(err.message || 'Fehler beim Speichern.'); - UI.setLoading(saveBtn, false); - } - }); - } - // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 1bd36f9..e9ed5b4 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -28,10 +28,6 @@ window.Page_dog_profile = (() => { if (e.target.closest('#profile-goto-login')) { App.navigate('settings'); } - if (e.target.closest('[data-action="goto-weight"]')) { - App.navigate('health', true, { tab: 'gewicht', openForm: true }); - return; - } }); await _render(); @@ -123,7 +119,7 @@ window.Page_dog_profile = (() => {
` : ''} ${dog.gewicht_kg ? ` -
+
Gewicht
${dog.gewicht_kg} kg
diff --git a/backend/static/js/pages/erste-hilfe.js b/backend/static/js/pages/erste-hilfe.js index 5e9d262..5e35644 100644 --- a/backend/static/js/pages/erste-hilfe.js +++ b/backend/static/js/pages/erste-hilfe.js @@ -186,7 +186,7 @@ window.Page_erste_hilfe = (() => { Bedeutung - Rosa, feuchtNormal + Rosa, feuchtNormal Blass / weißSchock, Blutverlust, Vergiftung Blau / grauSauerstoffmangel — NOTFALL GelbLeberprobleme @@ -239,7 +239,6 @@ window.Page_erste_hilfe = (() => { `; _bindTabs(); _bindAccordions(); - _bindNoteButtons(); _activateTab('lebensgefahr'); } @@ -341,10 +340,6 @@ window.Page_erste_hilfe = (() => { ${massnahmenHtml} ${warnHtml} ${e.extra || ''} -
- -
`; @@ -387,102 +382,6 @@ window.Page_erste_hilfe = (() => { }); } - function _bindNoteButtons() { - _container.querySelectorAll('.eh-note-btn').forEach(btn => { - btn.addEventListener('click', e => { - e.stopPropagation(); - const katId = btn.dataset.katId; - const titel = btn.dataset.titel; - const kat = KATEGORIEN.find(k => k.id === katId); - const label = kat ? `${kat.label} — ${titel}` : titel; - _openNoteModal('erste_hilfe', katId, label, null); - }); - }); - } - - // ---------------------------------------------------------------- - // NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden) - // ---------------------------------------------------------------- - async function _openNoteModal(parentType, parentId, parentLabel, locationName) { - document.getElementById('by-note-modal')?.remove(); - - const overlay = document.createElement('div'); - overlay.id = 'by-note-modal'; - overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center'; - - const _esc = s => s ? String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"') : ''; - - overlay.innerHTML = ` -
-
-
-
Notiz
-
${_esc(parentLabel)}
-
- -
-
-
- -
-
-
- - -
-
- `; - - document.body.appendChild(overlay); - - const textarea = document.getElementById('by-note-text'); - const saveBtn = document.getElementById('by-note-save'); - const cancelBtn = document.getElementById('by-note-cancel'); - const closeBtn = document.getElementById('by-note-close'); - - let existingNoteId = null; - - try { - const existing = await API.notes.get(parentType, parentId); - if (existing?.id) { - existingNoteId = existing.id; - textarea.value = existing.text || ''; - } - } catch (_) { /* keine Notiz vorhanden — ok */ } - - setTimeout(() => textarea.focus(), 100); - - const _close = () => overlay.remove(); - closeBtn.addEventListener('click', _close); - cancelBtn.addEventListener('click', _close); - overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); - - document.getElementById('by-note-form').addEventListener('submit', async e => { - e.preventDefault(); - const text = textarea.value.trim(); - UI.setLoading(saveBtn, true); - try { - const payload = { text, parent_label: parentLabel, location_name: locationName }; - if (existingNoteId) { - await API.notes.update(existingNoteId, payload); - } else { - await API.notes.create(parentType, parentId, payload); - } - UI.toast.success('Notiz gespeichert.'); - _close(); - } catch (err) { - UI.toast.error(err.message || 'Fehler beim Speichern.'); - UI.setLoading(saveBtn, false); - } - }); - } - // ---------------------------------------------------------------- // PUBLIC // ---------------------------------------------------------------- diff --git a/backend/static/js/pages/events.js b/backend/static/js/pages/events.js index 7308eb2..2d1dedf 100644 --- a/backend/static/js/pages/events.js +++ b/backend/static/js/pages/events.js @@ -226,14 +226,7 @@ window.Page_events = (() => {
` : ''} -
- ${isOwn ? `` : ''} - ${_state.user ? `` : ''} -
+ ${isOwn ? `` : ''} `; } @@ -275,7 +268,7 @@ window.Page_events = (() => { const popup = `
${UI.escape(ev.titel)}
- ${datum}
+ ${datum}
${ev.ort_name ? `📍 ${UI.escape(ev.ort_name)}
` : ''} ${ev.beschreibung ? `${UI.escape(ev.beschreibung.slice(0, 80))}${ev.beschreibung.length > 80 ? '…' : ''}
` : ''} { return; } - // Notiz-Button - const noteBtn = e.target.closest('.ev-note-btn'); - if (noteBtn) { - e.stopPropagation(); - _openNoteModal( - 'event', - parseInt(noteBtn.dataset.evNoteId), - noteBtn.dataset.evNoteLabel, - noteBtn.dataset.evNoteOrt || null - ); - return; - } - // Karten-Klick → Detail const card = e.target.closest('[data-ev-id]'); if (card) { _showDetail(parseInt(card.dataset.evId)); } } - // ---------------------------------------------------------- - // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) - // ---------------------------------------------------------- - async function _openNoteModal(parentType, parentId, parentLabel, locationName) { - let existingNote = null; - try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {} - - const ovl = document.createElement('div'); - ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center'; - ovl.innerHTML = ` -
-
- - Notiz — ${UI.escape(parentLabel)} - -
- -
- - -
-
- `; - document.body.appendChild(ovl); - - const close = () => ovl.remove(); - ovl.querySelector('#ev-note-close')?.addEventListener('click', close); - ovl.querySelector('#ev-note-cancel')?.addEventListener('click', close); - ovl.addEventListener('click', e => { if (e.target === ovl) close(); }); - - ovl.querySelector('#ev-note-save')?.addEventListener('click', async () => { - const text = ovl.querySelector('#ev-note-text')?.value?.trim() || ''; - const payload = { text, parent_label: parentLabel, location_name: locationName || null }; - try { - if (existingNote?.id) { - await API.notes.update(existingNote.id, payload); - } else { - await API.notes.create(parentType, String(parentId), payload); - } - UI.toast.success('Notiz gespeichert.'); - close(); - } catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); } - }); - } - return { init, refresh, openNew, _openDetail: _showDetail }; })(); diff --git a/backend/static/js/pages/friends.js b/backend/static/js/pages/friends.js index 514f18a..7e25d83 100644 --- a/backend/static/js/pages/friends.js +++ b/backend/static/js/pages/friends.js @@ -44,7 +44,7 @@ window.Page_friends = (() => {
-
diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index dced726..5ffc78a 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -34,17 +34,10 @@ window.Page_health = (() => { // ---------------------------------------------------------- // LIFECYCLE // ---------------------------------------------------------- - async function init(container, appState, params) { + async function init(container, appState) { _container = container; _appState = appState; - if (params?.tab) { - const valid = _getTabs().some(t => t.key === params.tab); - if (valid) _activeTab = params.tab; - } await _render(); - if (params?.openForm) { - setTimeout(() => _showForm(null, _activeTab), 200); - } } async function refresh() { @@ -407,10 +400,6 @@ window.Page_health = (() => { Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
` : ''} ${e.notiz ? `
${_esc(e.notiz)}
` : ''} - `; @@ -456,10 +445,6 @@ window.Page_health = (() => { ` : ''} ${e.diagnose ? `
Diagnose: ${_esc(e.diagnose)}
` : ''} ${e.notiz ? `
${_esc(e.notiz)}
` : ''} - `; @@ -508,10 +493,6 @@ window.Page_health = (() => { ${e.notiz ? `
${_esc(e.notiz)}
` : ''} - `).join(''); @@ -745,10 +726,6 @@ window.Page_health = (() => { ${interval ? ` · Abstand zur Vorherigen: ${interval} Tage` : ''} ${e.notiz ? `
${_esc(e.notiz)}
` : ''} - `; }).join(''); @@ -783,10 +760,6 @@ window.Page_health = (() => { ${e.bis_datum ? ` · bis ${UI.time.format(e.bis_datum + 'T00:00:00')}` : ''} ${e.notiz ? `
${_esc(e.notiz)}
` : ''} - `).join('')} @@ -824,10 +797,6 @@ window.Page_health = (() => { ${e.reaktion ? `
Reaktion: ${_esc(e.reaktion)}
` : ''} ${e.notiz ? `
${_esc(e.notiz)}
` : ''} - `).join(''); @@ -868,10 +837,6 @@ window.Page_health = (() => { ${count > 1 ? ` · ${count} Dateien` : ''} ${e.notiz ? `
${_esc(e.notiz)}
` : ''} - ${count ? `
${mediaList.slice(0, 3).map(m => m.media_type === 'pdf' @@ -909,14 +874,6 @@ window.Page_health = (() => { const entry = (_data[_activeTab] || []).find(e => e.id === id); if (entry) card.addEventListener('click', () => _openDetail(entry)); }); - content.querySelectorAll('[data-action="open-note"]').forEach(btn => { - btn.addEventListener('click', e => { - e.stopPropagation(); - const id = parseInt(btn.dataset.entryId); - const label = btn.dataset.label || ''; - _openNoteModal('health', id, label, null); - }); - }); // Praxis öffnen content.querySelectorAll('[data-action="open-praxis"]').forEach(el => { el.addEventListener('click', () => { @@ -1209,9 +1166,6 @@ window.Page_health = (() => { if (!_data[t]) _data[t] = []; _data[t].unshift(saved); UI.toast.success('Eintrag erstellt.'); - if (t === 'gewicht' && saved.wert) { - _appState.activeDog.gewicht_kg = saved.wert; - } } // Multi-File-Upload @@ -1876,89 +1830,6 @@ window.Page_health = (() => { .replace(/"/g, '"'); } - // ---------------------------------------------------------- - // NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden) - // ---------------------------------------------------------- - async function _openNoteModal(parentType, parentId, parentLabel, locationName) { - // Vorhandenes Modal entfernen falls noch offen - document.getElementById('by-note-modal')?.remove(); - - const overlay = document.createElement('div'); - overlay.id = 'by-note-modal'; - overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center'; - - overlay.innerHTML = ` -
-
-
-
Notiz
-
${_esc(parentLabel)}
-
- -
-
-
- -
-
-
- - -
-
- `; - - document.body.appendChild(overlay); - - const textarea = document.getElementById('by-note-text'); - const saveBtn = document.getElementById('by-note-save'); - const cancelBtn = document.getElementById('by-note-cancel'); - const closeBtn = document.getElementById('by-note-close'); - - let existingNoteId = null; - - // Vorhandene Notiz laden - try { - const existing = await API.notes.get(parentType, parentId); - if (existing?.id) { - existingNoteId = existing.id; - textarea.value = existing.text || ''; - } - } catch (_) { /* keine Notiz vorhanden — ok */ } - - setTimeout(() => textarea.focus(), 100); - - const _close = () => overlay.remove(); - closeBtn.addEventListener('click', _close); - cancelBtn.addEventListener('click', _close); - overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); - - document.getElementById('by-note-form').addEventListener('submit', async e => { - e.preventDefault(); - const text = textarea.value.trim(); - UI.setLoading(saveBtn, true); - try { - const payload = { text, parent_label: parentLabel, location_name: locationName }; - if (existingNoteId) { - await API.notes.update(existingNoteId, payload); - } else { - await API.notes.create(parentType, parentId, payload); - } - UI.toast.success('Notiz gespeichert.'); - _close(); - } catch (err) { - UI.toast.error(err.message || 'Fehler beim Speichern.'); - UI.setLoading(saveBtn, false); - } - }); - } - return { init, refresh, openNew, onDogChange }; })(); diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 67b6e5a..3c5fad5 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -286,35 +286,33 @@ window.Page_map = (() => { // Leaflet + MarkerCluster laden // ---------------------------------------------------------- async function _loadLeaflet() { - if (_leafletLoaded) return; + if (_leafletLoaded || window.L) { _leafletLoaded = true; return; } - // Leaflet-Basis: nur laden wenn noch nicht vorhanden (diary.js kann es vorgeladen haben) - if (!window.L) { - const lCss = document.createElement('link'); - lCss.rel = 'stylesheet'; lCss.href = '/css/leaflet.css'; - document.head.appendChild(lCss); + // Leaflet CSS + const lCss = document.createElement('link'); + lCss.rel = 'stylesheet'; lCss.href = '/css/leaflet.css'; + document.head.appendChild(lCss); - await new Promise(resolve => { - const s = document.createElement('script'); - s.src = '/js/leaflet.js'; s.onload = resolve; - document.head.appendChild(s); - }); - } + // Leaflet JS + await new Promise(resolve => { + const s = document.createElement('script'); + s.src = '/js/leaflet.js'; s.onload = resolve; + document.head.appendChild(s); + }); - // MarkerCluster: separat prüfen — diary.js lädt Leaflet ohne MarkerCluster - if (!window.L.markerClusterGroup) { - ['MarkerCluster.css', 'MarkerCluster.Default.css'].forEach(f => { - const l = document.createElement('link'); - l.rel = 'stylesheet'; l.href = `/css/${f}`; - document.head.appendChild(l); - }); + // MarkerCluster CSS + ['MarkerCluster.css', 'MarkerCluster.Default.css'].forEach(f => { + const l = document.createElement('link'); + l.rel = 'stylesheet'; l.href = `/css/${f}`; + document.head.appendChild(l); + }); - await new Promise(resolve => { - const s = document.createElement('script'); - s.src = '/js/leaflet.markercluster.js'; s.onload = resolve; - document.head.appendChild(s); - }); - } + // MarkerCluster JS + await new Promise(resolve => { + const s = document.createElement('script'); + s.src = '/js/leaflet.markercluster.js'; s.onload = resolve; + document.head.appendChild(s); + }); _leafletLoaded = true; } diff --git a/backend/static/js/pages/notes.js b/backend/static/js/pages/notes.js deleted file mode 100644 index 97247df..0000000 --- a/backend/static/js/pages/notes.js +++ /dev/null @@ -1,693 +0,0 @@ -/* ============================================================ - BAN YARO — Notizblock - Seiten-Modul: Alle Notizen mit Filter, Suche, Sortierung und KI-Analyse. - ============================================================ */ - -window.Page_notes = (() => { - - let _container = null; - let _appState = null; - let _notes = []; - - // Aktueller Filter-/Such-Zustand - let _filterType = ''; // '' = alle - let _sortMode = 'newest'; // newest | type | location - let _searchQ = ''; - let _searchTimer = null; - - // KI-Panel - let _kiOpen = false; - let _kiLoading = false; - let _kiSuggestions = null; - let _kiError = null; - - // ---------------------------------------------------------- - // Rubrik-Konfiguration - // ---------------------------------------------------------- - const RUBRIKEN = [ - { type: '', label: 'Alle', color: 'var(--c-text-muted)', icon: 'note' }, - { type: 'health', label: 'Gesundheit', color: '#e74c3c', icon: 'heart' }, - { type: 'diary', label: 'Tagebuch', color: '#C4843A', icon: 'book-open' }, - { type: 'training_session', label: 'Training', color: '#27ae60', icon: 'target' }, - { type: 'route', label: 'Routen', color: '#2980b9', icon: 'path' }, - { type: 'event', label: 'Events', color: '#8e44ad', icon: 'calendar' }, - { type: 'walk', label: 'Gassi-Treffen',color: '#f39c12', icon: 'paw-print' }, - { type: 'sitting', label: 'Sitting', color: '#16a085', icon: 'house-line' }, - { type: 'erste_hilfe', label: 'Erste Hilfe', color: '#c0392b', icon: 'first-aid' }, - ]; - - function _rubrik(type) { - return RUBRIKEN.find(r => r.type === type) || { type, label: type, color: 'var(--c-text-muted)', icon: 'note' }; - } - - // ---------------------------------------------------------- - // Hilfsfunktionen - // ---------------------------------------------------------- - function _esc(s) { - if (!s) return ''; - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } - - function _formatTime(isoStr) { - if (!isoStr) return ''; - try { - const d = new Date(isoStr.replace(' ', 'T') + (isoStr.includes('T') || isoStr.endsWith('Z') ? '' : 'Z')); - return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); - } catch (_) { return ''; } - } - - function _dateGroup(isoStr) { - if (!isoStr) return 'Älteres'; - try { - const d = new Date(isoStr.replace(' ', 'T') + (isoStr.includes('T') || isoStr.endsWith('Z') ? '' : 'Z')); - const now = new Date(); - const diffDays = (now - d) / 86400000; - if (diffDays < 1 && d.getDate() === now.getDate()) return 'Heute'; - if (diffDays < 7) return 'Diese Woche'; - return 'Älteres'; - } catch (_) { return 'Älteres'; } - } - - function _truncate(str, max = 150) { - if (!str) return ''; - return str.length > max ? str.slice(0, max) + '…' : str; - } - - // ---------------------------------------------------------- - // Daten laden - // ---------------------------------------------------------- - async function _load() { - const params = {}; - if (_filterType) params.parent_type = _filterType; - if (_sortMode !== 'newest') params.sort = _sortMode; - if (_searchQ) params.q = _searchQ; - return await API.notes.getAll(params); - } - - // ---------------------------------------------------------- - // Filter/Sortierung anwenden (client-seitig falls API alles zurückgibt) - // ---------------------------------------------------------- - function _applySort(list) { - const copy = [...list]; - if (_sortMode === 'newest') { - copy.sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at)); - } else if (_sortMode === 'type') { - copy.sort((a, b) => (a.parent_type || '').localeCompare(b.parent_type || '', 'de')); - } else if (_sortMode === 'location') { - copy.sort((a, b) => (a.location_name || '').localeCompare(b.location_name || '', 'de')); - } - return copy; - } - - // ---------------------------------------------------------- - // Rendern - // ---------------------------------------------------------- - function _render() { - const kiEnabled = _appState?.user?.notes_ki_enabled !== 0; - const sorted = _applySort(_notes); - - // Gruppen aufbauen - const groups = { 'Heute': [], 'Diese Woche': [], 'Älteres': [] }; - sorted.forEach(n => { - const g = _dateGroup(n.updated_at || n.created_at); - groups[g].push(n); - }); - - const groupHtml = Object.entries(groups) - .filter(([, items]) => items.length > 0) - .map(([label, items]) => ` -
-
${_esc(label)}
- ${items.map(_noteCard).join('')} -
- `).join(''); - - _container.innerHTML = ` -
- - -
-

Notizblock

- ${_notes.length} Notiz${_notes.length !== 1 ? 'en' : ''} -
- - - ${kiEnabled ? _kiPanelHtml() : ''} - - -
- ${RUBRIKEN.map(r => ` - - `).join('')} -
- - -
-
- - -
-
- - - -
-
- - -
- ${sorted.length === 0 - ? UI.emptyState({ icon: 'note', title: 'Keine Notizen', text: 'Füge Notizen zu Trainingseinheiten oder anderen Einträgen hinzu.' }) - : groupHtml - } -
- -
- - - `; - - _bindEvents(); - } - - // ---------------------------------------------------------- - // KI-Panel HTML - // ---------------------------------------------------------- - function _kiPanelHtml() { - return ` -
-
-
- - Muster-Analyse -
- -
- ${_kiOpen ? ` -
- - ${_kiError ? `
${_esc(_kiError)}
` : ''} - ${_kiSuggestions ? ` -
-
    - ${_kiSuggestions.map(s => `
  • ${_esc(s)}
  • `).join('')} -
-
- ` : ''} -
- ` : ''} -
- `; - } - - // ---------------------------------------------------------- - // Notiz-Karte HTML - // ---------------------------------------------------------- - function _noteCard(note) { - const rb = _rubrik(note.parent_type); - const meta = note.meta_json || {}; - - const microBadges = []; - if (meta.erfolgsquote) microBadges.push('🐾'.repeat(meta.erfolgsquote)); - if (meta.umgebung) microBadges.push({ zuhause: '🏠 Zuhause', natur: '🌿 Natur', stadt: '🌆 Stadt' }[meta.umgebung] || meta.umgebung); - if (meta.hund_stimmung) microBadges.push({ super: '😊 Super', ok: '😐 Ok', mude: '😔 Müde' }[meta.hund_stimmung] || meta.hund_stimmung); - - const hasLocation = !!note.location_name; - - return ` -
- -
- - - ${_esc(rb.label)} - - ${note.parent_label - ? `${_esc(note.parent_label)}` - : '' - } -
- - -
-
- - -

${_esc(_truncate(note.text))}

- - - ${microBadges.length ? ` -
- ${microBadges.map(b => `${_esc(b)}`).join('')} -
- ` : ''} - - -
- - ${_esc(_formatTime(note.updated_at || note.created_at))} - ${hasLocation ? ` ${_esc(note.location_name)}` : ''} -
-
- `; - } - - // ---------------------------------------------------------- - // Event-Binding - // ---------------------------------------------------------- - function _bindEvents() { - - // Filter-Chips - _container.querySelectorAll('.notes-chip').forEach(btn => { - btn.addEventListener('click', () => { - _filterType = btn.dataset.type; - _reload(); - }); - }); - - // Sortierung - _container.querySelectorAll('.notes-sort-btn').forEach(btn => { - btn.addEventListener('click', () => { - _sortMode = btn.dataset.sort; - _render(); // nur neu rendern, keine API-Last - }); - }); - - // Suche (debounced) - const searchInput = _container.querySelector('#notes-search'); - if (searchInput) { - searchInput.addEventListener('input', () => { - clearTimeout(_searchTimer); - _searchTimer = setTimeout(() => { - _searchQ = searchInput.value.trim(); - _reload(); - }, 300); - }); - } - - // KI-Toggle - const kiToggle = _container.querySelector('#notes-ki-toggle'); - if (kiToggle) { - kiToggle.addEventListener('click', () => { - _kiOpen = !_kiOpen; - _render(); - }); - } - - // KI-Analyse-Button - const kiBtn = _container.querySelector('#notes-ki-analyse-btn'); - if (kiBtn) { - kiBtn.addEventListener('click', async () => { - _kiLoading = true; - _kiError = null; - _kiSuggestions = null; - _render(); - try { - const res = await API.notes.analyse(); - if (res && Array.isArray(res.suggestions)) { - _kiSuggestions = res.suggestions; - } else if (res && res.text) { - _kiSuggestions = res.text.split('\n').filter(Boolean); - } else { - _kiSuggestions = ['Keine Vorschläge verfügbar.']; - } - } catch (err) { - _kiError = err?.message || 'KI-Analyse nicht verfügbar.'; - } finally { - _kiLoading = false; - _render(); - } - }); - } - - // Edit-Buttons - _container.querySelectorAll('.notes-edit-btn').forEach(btn => { - btn.addEventListener('click', e => { - e.stopPropagation(); - const note = _notes.find(n => n.id === parseInt(btn.dataset.id, 10)); - if (note) _openEditModal(note); - }); - }); - - // Delete-Buttons - _container.querySelectorAll('.notes-delete-btn').forEach(btn => { - btn.addEventListener('click', async e => { - e.stopPropagation(); - const noteId = parseInt(btn.dataset.id, 10); - if (!window.confirm('Notiz wirklich löschen?')) return; - try { - await API.notes.delete(noteId); - _notes = _notes.filter(n => n.id !== noteId); - _render(); - UI.toast.success('Notiz gelöscht.'); - } catch (_) { - UI.toast.error('Löschen fehlgeschlagen.'); - } - }); - }); - } - - // ---------------------------------------------------------- - // Laden + Re-Render - // ---------------------------------------------------------- - async function _reload() { - _container.querySelector('.notes-list')?.classList.add('loading'); - try { - _notes = await _load(); - } catch (_) { - _notes = []; - } - _render(); - } - - // ---------------------------------------------------------- - // Edit-Modal (Bottom-Sheet Stil) - // ---------------------------------------------------------- - function _openEditModal(note) { - const meta = note.meta_json || {}; - const rb = _rubrik(note.parent_type); - - const modalId = 'notes-edit-modal'; - document.getElementById(modalId)?.remove(); - - const overlay = document.createElement('div'); - overlay.id = modalId; - overlay.style.cssText = ` - position:fixed;inset:0;z-index:9999; - display:flex;align-items:flex-end;justify-content:center; - background:rgba(0,0,0,0.45); - `; - - overlay.innerHTML = ` -
- - -
- - -
- - ${_esc(rb.label)} - -

- Notiz bearbeiten -

-
- -
- - -
- - -
- - ${note.parent_type === 'training_session' ? ` - -
- -
- ${[1,2,3,4,5].map(n => ` - - `).join('')} -
-
- - -
- -
- ${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji,val]) => ` - - `).join('')} -
-
- - -
- -
- ${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji,val]) => ` - - `).join('')} -
-
- ` : ''} - -
- - -
- - - -
-
- `; - - document.body.appendChild(overlay); - - let selErfolgsquote = meta.erfolgsquote || null; - let selUmgebung = meta.umgebung || null; - let selStimmung = meta.hund_stimmung || null; - - function _toggleBtn(group, val, getter, setter) { - overlay.querySelectorAll(`.notes-${group}`).forEach(b => { - const match = (group === 'pfote') - ? parseInt(b.dataset.val, 10) === val - : b.dataset.val === val; - b.style.background = match ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'; - b.style.borderColor = match ? 'var(--c-primary)' : 'var(--c-border)'; - }); - } - - overlay.querySelectorAll('.notes-pfote').forEach(btn => { - btn.addEventListener('click', () => { - const v = parseInt(btn.dataset.val, 10); - selErfolgsquote = selErfolgsquote === v ? null : v; - _toggleBtn('pfote', selErfolgsquote, null, null); - }); - }); - - overlay.querySelectorAll('.notes-umgebung').forEach(btn => { - btn.addEventListener('click', () => { - selUmgebung = selUmgebung === btn.dataset.val ? null : btn.dataset.val; - _toggleBtn('umgebung', selUmgebung, null, null); - }); - }); - - overlay.querySelectorAll('.notes-stimmung').forEach(btn => { - btn.addEventListener('click', () => { - selStimmung = selStimmung === btn.dataset.val ? null : btn.dataset.val; - _toggleBtn('stimmung', selStimmung, null, null); - }); - }); - - function _close() { overlay.remove(); } - overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); - overlay.querySelector('#notes-edit-cancel').addEventListener('click', _close); - - // Speichern - overlay.querySelector('#notes-edit-save').addEventListener('click', async () => { - const text = overlay.querySelector('#notes-edit-text').value.trim(); - if (!text) { UI.toast.warning('Notiz darf nicht leer sein.'); return; } - - const saveBtn = overlay.querySelector('#notes-edit-save'); - saveBtn.disabled = true; - saveBtn.textContent = 'Speichern…'; - - const metaObj = {}; - if (selErfolgsquote) metaObj.erfolgsquote = selErfolgsquote; - if (selUmgebung) metaObj.umgebung = selUmgebung; - if (selStimmung) metaObj.hund_stimmung = selStimmung; - - try { - const updated = await API.notes.update(note.id, { - text, - meta_json: Object.keys(metaObj).length > 0 ? metaObj : null, - }); - const idx = _notes.findIndex(n => n.id === note.id); - if (idx >= 0) _notes[idx] = updated; - _render(); - _close(); - UI.toast.success('Notiz aktualisiert.'); - } catch (_) { - saveBtn.disabled = false; - saveBtn.textContent = 'Speichern'; - UI.toast.error('Speichern fehlgeschlagen.'); - } - }); - - // Löschen - overlay.querySelector('#notes-edit-delete').addEventListener('click', async () => { - if (!window.confirm('Notiz wirklich löschen?')) return; - try { - await API.notes.delete(note.id); - _notes = _notes.filter(n => n.id !== note.id); - _render(); - _close(); - UI.toast.success('Notiz gelöscht.'); - } catch (_) { - UI.toast.error('Löschen fehlgeschlagen.'); - } - }); - } - - // ---------------------------------------------------------- - // INIT / REFRESH - // ---------------------------------------------------------- - async function init(container, appState) { - _container = container; - _appState = appState; - - // Zustand zurücksetzen - _filterType = ''; - _sortMode = 'newest'; - _searchQ = ''; - _kiOpen = false; - _kiLoading = false; - _kiSuggestions = null; - _kiError = null; - _notes = []; - - _container.innerHTML = UI.skeleton(3); - - try { - _notes = await _load(); - } catch (_) { - _notes = []; - } - - _render(); - } - - async function refresh() { - if (!_container) return; - _container.innerHTML = UI.skeleton(3); - try { - _notes = await _load(); - } catch (_) { - _notes = []; - } - _render(); - } - - return { init, refresh }; - -})(); diff --git a/backend/static/js/pages/onboarding.js b/backend/static/js/pages/onboarding.js index d2beee7..6cfda8b 100644 --- a/backend/static/js/pages/onboarding.js +++ b/backend/static/js/pages/onboarding.js @@ -126,7 +126,7 @@ window.Page_onboarding = (() => {
-
@@ -167,7 +167,7 @@ window.Page_onboarding = (() => {
-
@@ -262,7 +262,7 @@ window.Page_onboarding = (() => {
-
diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index f3296b1..8641995 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -1805,7 +1805,6 @@ window.Page_routes = (() => { ${_actionBtn('rd-gpx', 'download-simple', 'GPX')} ${_actionBtn('rd-share', 'arrow-square-out', 'Teilen')} ${_actionBtn('rd-navi', 'map-pin', 'Navi')} - ${_appState.user ? _actionBtn('rd-note', 'note-pencil', 'Notiz') : ''}
${ownerRow} @@ -1921,12 +1920,6 @@ window.Page_routes = (() => { } catch (err) { UI.toast.error(err.message); } }); - // Notiz-Button - document.getElementById('rd-note')?.addEventListener('click', () => { - const label = route.name || (route.distanz_km ? route.distanz_km.toFixed(1) + ' km' : 'Route'); - _openNoteModal('route', route.id, label, null); - }); - // Mini-Map let _detailMap = null; setTimeout(() => { @@ -2511,59 +2504,6 @@ window.Page_routes = (() => { }); } - // ---------------------------------------------------------- - // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) - // ---------------------------------------------------------- - async function _openNoteModal(parentType, parentId, parentLabel, locationName) { - let existingNote = null; - try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {} - - const ovl = document.createElement('div'); - ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center'; - ovl.innerHTML = ` -
-
- - Notiz — ${UI.escape(parentLabel)} - -
- -
- - -
-
- `; - document.body.appendChild(ovl); - - const close = () => ovl.remove(); - ovl.querySelector('#rk-note-close')?.addEventListener('click', close); - ovl.querySelector('#rk-note-cancel')?.addEventListener('click', close); - ovl.addEventListener('click', e => { if (e.target === ovl) close(); }); - - ovl.querySelector('#rk-note-save')?.addEventListener('click', async () => { - const text = ovl.querySelector('#rk-note-text')?.value?.trim() || ''; - const payload = { text, parent_label: parentLabel, location_name: locationName || null }; - try { - if (existingNote?.id) { - await API.notes.update(existingNote.id, payload); - } else { - await API.notes.create(parentType, String(parentId), payload); - } - UI.toast.success('Notiz gespeichert.'); - close(); - } catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); } - }); - } - return { init, refresh, onDogChange }; })(); diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 0383496..d4a45fd 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -266,30 +266,6 @@ window.Page_settings = (() => { - -
- -
-
KI-Notiz-Assistent
-
- Erkennt Muster in deinen Notizen und macht Vorschläge -
-
- -
- @@ -659,25 +635,6 @@ window.Page_settings = (() => { : 'Pocket-Modus deaktiviert.'); }); - document.getElementById('toggle-notes-ki')?.addEventListener('change', async e => { - const enabled = e.target.checked; - const track = document.getElementById('toggle-notes-ki-track'); - const thumb = document.getElementById('toggle-notes-ki-thumb'); - if (track) track.style.background = enabled ? 'var(--c-primary)' : 'var(--c-border)'; - if (thumb) thumb.style.left = enabled ? '22px' : '2px'; - try { - await API.patch('/profile', { notes_ki_enabled: enabled ? 1 : 0 }); - _appState.user.notes_ki_enabled = enabled ? 1 : 0; - UI.toast.success(enabled ? 'KI-Notiz-Assistent aktiviert.' : 'KI-Notiz-Assistent deaktiviert.'); - } catch (err) { - UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.'); - // Revert UI - e.target.checked = !enabled; - if (track) track.style.background = !enabled ? 'var(--c-primary)' : 'var(--c-border)'; - if (thumb) thumb.style.left = !enabled ? '22px' : '2px'; - } - }); - _loadReferral(); } diff --git a/backend/static/js/pages/sitting.js b/backend/static/js/pages/sitting.js index 4df9fda..c825fa0 100644 --- a/backend/static/js/pages/sitting.js +++ b/backend/static/js/pages/sitting.js @@ -136,12 +136,6 @@ window.Page_sitting = (() => {
${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €/Tag' : 'Preis anfragen'}
max. ${s.max_hunde} Hund${s.max_hunde !== 1 ? 'e' : ''}
- ${_state.user ? `` : ''}
`; @@ -710,19 +704,6 @@ window.Page_sitting = (() => { return; } - // Notiz-Button auf Sitter-Karte - const noteBtn = e.target.closest('.sit-note-btn'); - if (noteBtn) { - e.stopPropagation(); - _openNoteModal( - 'sitting', - parseInt(noteBtn.dataset.sitNoteId), - noteBtn.dataset.sitNoteLabel, - null - ); - return; - } - // Sitter-Karte const sitterCard = e.target.closest('[data-sit-id]'); if (sitterCard && !e.target.closest('button')) { @@ -760,59 +741,6 @@ window.Page_sitting = (() => { } catch (e) { UI.toast(e.message, 'error'); } } - // ---------------------------------------------------------- - // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) - // ---------------------------------------------------------- - async function _openNoteModal(parentType, parentId, parentLabel, locationName) { - let existingNote = null; - try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {} - - const ovl = document.createElement('div'); - ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center'; - ovl.innerHTML = ` -
-
- - Notiz — ${UI.escape(parentLabel)} - -
- -
- - -
-
- `; - document.body.appendChild(ovl); - - const close = () => ovl.remove(); - ovl.querySelector('#sit-note-close')?.addEventListener('click', close); - ovl.querySelector('#sit-note-cancel')?.addEventListener('click', close); - ovl.addEventListener('click', e => { if (e.target === ovl) close(); }); - - ovl.querySelector('#sit-note-save')?.addEventListener('click', async () => { - const text = ovl.querySelector('#sit-note-text')?.value?.trim() || ''; - const payload = { text, parent_label: parentLabel, location_name: locationName || null }; - try { - if (existingNote?.id) { - await API.notes.update(existingNote.id, payload); - } else { - await API.notes.create(parentType, String(parentId), payload); - } - UI.toast.success('Notiz gespeichert.'); - close(); - } catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); } - }); - } - return { init, refresh }; })(); diff --git a/backend/static/js/pages/uebungen.js b/backend/static/js/pages/uebungen.js index 0d8e48a..0450d1f 100644 --- a/backend/static/js/pages/uebungen.js +++ b/backend/static/js/pages/uebungen.js @@ -895,7 +895,6 @@ window.Page_uebungen = (() => { _bindAccordions(); _bindStatusButtons(); _bindLogButtons(); - _bindNotizButtons(); if (_activeTab === 'ki-trainer') _loadKiTrainerFeedback(); } @@ -966,19 +965,6 @@ window.Page_uebungen = (() => { Einheit ${_sessionStatsChip(_activeTab, u.name)} - - `).join('')} - - - - -
- -
- ${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji, val]) => ` - - `).join('')} -
-
- - -
- -
- ${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji, val]) => ` - - `).join('')} -
-
- - - - -
- ${existingNote ? ` - - ` : ''} - - -
- - `; - - document.body.appendChild(overlay); - - // State - let selectedErfolgsquote = currentErfolgsquote; - let selectedUmgebung = currentUmgebung; - let selectedStimmung = currentStimmung; - - // Pfoten-Buttons - overlay.querySelectorAll('.ueb-notiz-pfote').forEach(btn => { - btn.addEventListener('click', () => { - const val = parseInt(btn.dataset.val, 10); - selectedErfolgsquote = selectedErfolgsquote === val ? null : val; - overlay.querySelectorAll('.ueb-notiz-pfote').forEach(b => { - const active = parseInt(b.dataset.val, 10) === selectedErfolgsquote; - b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'; - b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)'; - }); - }); - }); - - // Umgebung-Buttons - overlay.querySelectorAll('.ueb-notiz-umgebung').forEach(btn => { - btn.addEventListener('click', () => { - selectedUmgebung = selectedUmgebung === btn.dataset.val ? null : btn.dataset.val; - overlay.querySelectorAll('.ueb-notiz-umgebung').forEach(b => { - const active = b.dataset.val === selectedUmgebung; - b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'; - b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)'; - }); - }); - }); - - // Stimmung-Buttons - overlay.querySelectorAll('.ueb-notiz-stimmung').forEach(btn => { - btn.addEventListener('click', () => { - selectedStimmung = selectedStimmung === btn.dataset.val ? null : btn.dataset.val; - overlay.querySelectorAll('.ueb-notiz-stimmung').forEach(b => { - const active = b.dataset.val === selectedStimmung; - b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'; - b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)'; - }); - }); - }); - - function _closeNotizModal() { - overlay.remove(); - } - - overlay.addEventListener('click', e => { if (e.target === overlay) _closeNotizModal(); }); - overlay.querySelector('#ueb-notiz-cancel').addEventListener('click', _closeNotizModal); - - // Speichern - overlay.querySelector('#ueb-notiz-save').addEventListener('click', async () => { - const text = overlay.querySelector('#ueb-notiz-text').value.trim(); - if (!text) { UI.toast.warning('Bitte gib eine Notiz ein.'); return; } - - const saveBtn = overlay.querySelector('#ueb-notiz-save'); - saveBtn.disabled = true; - saveBtn.textContent = 'Speichern…'; - - const meta = {}; - if (selectedErfolgsquote) meta.erfolgsquote = selectedErfolgsquote; - if (selectedUmgebung) meta.umgebung = selectedUmgebung; - if (selectedStimmung) meta.hund_stimmung = selectedStimmung; - - const payload = { - text, - meta_json: Object.keys(meta).length > 0 ? meta : null, - }; - - try { - if (existingNote) { - await API.notes.update(existingNote.id, payload); - } else { - await API.notes.create('training_session', exerciseId, payload); - } - _closeNotizModal(); - UI.toast.success('Notiz gespeichert.'); - // Notiz-Button leicht hervorheben - if (triggerBtn) { - triggerBtn.style.borderColor = 'var(--c-primary)'; - triggerBtn.style.color = 'var(--c-primary)'; - } - } catch (err) { - saveBtn.disabled = false; - saveBtn.textContent = existingNote ? 'Aktualisieren' : 'Speichern'; - UI.toast.error('Speichern fehlgeschlagen.'); - } - }); - - // Löschen - overlay.querySelector('#ueb-notiz-delete')?.addEventListener('click', async () => { - if (!existingNote) return; - try { - await API.notes.delete(existingNote.id); - _closeNotizModal(); - UI.toast.success('Notiz gelöscht.'); - if (triggerBtn) { - triggerBtn.style.borderColor = ''; - triggerBtn.style.color = ''; - } - } catch (_) { - UI.toast.error('Löschen fehlgeschlagen.'); - } - }); - } - function _openLogModal(tab, exerciseName, initialReps) { // Build the modal HTML const modalId = 'ueb-log-modal'; diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index 02a6bb6..34155a5 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -192,18 +192,6 @@ window.Page_walks = (() => { el.querySelectorAll('.walks-card').forEach(card => { card.addEventListener('click', () => _openDetail(parseInt(card.dataset.id))); }); - - el.querySelectorAll('.wk-note-btn').forEach(btn => { - btn.addEventListener('click', e => { - e.stopPropagation(); - _openNoteModal( - 'walk', - parseInt(btn.dataset.wkNoteId), - btn.dataset.wkNoteLabel, - btn.dataset.wkNoteOrt || null - ); - }); - }); } function _walkCardHTML(w) { @@ -229,16 +217,7 @@ window.Page_walks = (() => { ${isOwn ? 'Mein Treffen' : ''} -
-
- ${_appState.user ? `` : ''} -
+
`; } @@ -985,59 +964,6 @@ window.Page_walks = (() => { }); } - // ---------------------------------------------------------- - // Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden) - // ---------------------------------------------------------- - async function _openNoteModal(parentType, parentId, parentLabel, locationName) { - let existingNote = null; - try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {} - - const ovl = document.createElement('div'); - ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center'; - ovl.innerHTML = ` -
-
- - Notiz — ${UI.escape(parentLabel)} - -
- -
- - -
-
- `; - document.body.appendChild(ovl); - - const close = () => ovl.remove(); - ovl.querySelector('#wk-note-close')?.addEventListener('click', close); - ovl.querySelector('#wk-note-cancel')?.addEventListener('click', close); - ovl.addEventListener('click', e => { if (e.target === ovl) close(); }); - - ovl.querySelector('#wk-note-save')?.addEventListener('click', async () => { - const text = ovl.querySelector('#wk-note-text')?.value?.trim() || ''; - const payload = { text, parent_label: parentLabel, location_name: locationName || null }; - try { - if (existingNote?.id) { - await API.notes.update(existingNote.id, payload); - } else { - await API.notes.create(parentType, String(parentId), payload); - } - UI.toast.success('Notiz gespeichert.'); - close(); - } catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); } - }); - } - return { init, refresh, onDogChange, openNew, openDetail: _openDetail }; })(); diff --git a/backend/static/js/pages/welcome.js b/backend/static/js/pages/welcome.js index 388172c..68f28b7 100644 --- a/backend/static/js/pages/welcome.js +++ b/backend/static/js/pages/welcome.js @@ -53,7 +53,7 @@ window.Page_welcome = (() => { style="width:36px;height:36px;border-radius:var(--radius-md); background:var(--c-primary);flex-shrink:0; display:flex;align-items:center;justify-content:center"> -
-
diff --git a/backend/static/sw.js b/backend/static/sw.js index ca5421c..93bc520 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,15 +3,15 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v406'; +const CACHE_VERSION = 'by-v370'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten // index.html wird NICHT pre-gecacht (immer Network-First) const STATIC_ASSETS = [ - '/css/design-system.css?v=382', - '/css/layout.css?v=382', - '/css/components.css?v=382', + '/css/design-system.css', + '/css/layout.css', + '/css/components.css', '/icons/phosphor.svg', '/js/api.js', '/js/ui.js', @@ -82,8 +82,8 @@ self.addEventListener('fetch', event => { return; } - // CSS + Seiten-Module: immer Network-First — damit iOS nie veraltete CSS cached - if (url.pathname.startsWith('/css/') || url.pathname.startsWith('/js/pages/')) { + // Seiten-Module (/js/pages/…): immer Network-First (versioniert über ?v=, kein alter Cache-Treffer) + if (url.pathname.startsWith('/js/pages/')) { event.respondWith( fetch(event.request) .then(response => { diff --git a/promotion/banyaro_hundeschulen.html b/promotion/banyaro_hundeschulen.html deleted file mode 100644 index 6c637cf..0000000 --- a/promotion/banyaro_hundeschulen.html +++ /dev/null @@ -1,410 +0,0 @@ - - - - -Banyaro für Hundeschulen - - - -
- - -
-
-
Banyaro
-
Die App, die Ihre Trainingsstunden verlängert
-
-
-
Für Hundeschulen & Trainer
-
banyaro.app
-
-
- - -
Damit das, was Sie in der Stunde aufbauen, zu Hause nicht verloren geht.
-
- Die größte Hürde im Hundetraining ist nicht der Hund — es ist die Lücke zwischen den Stunden. - Banyaro gibt Ihren Kunden eine strukturierte Trainings-Begleitung an die Hand: Übungsbibliothek, - Trainingspläne, Tagebuch zum Festhalten von Fortschritten und ein Wiki mit fundiertem Wissen. - Das macht Ihre Arbeit nachhaltiger — und Ihre Kunden zufriedener. -
- - -
-
-
Zielgruppe
-
    -
  • Welpen- und Junghundebesitzer
  • -
  • Ersthundehalter mit hohem Lernbedarf
  • -
  • Engagierte Halter ab ca. 30 Jahren
  • -
  • Wiedereinsteiger nach längerer Pause
  • -
  • Sportlich oder thematisch ambitionierte Teams
  • -
-
-
-
Nutzen für Ihre Schule
-
    -
  • Kunden trainieren strukturierter zwischen den Stunden
  • -
  • Sichtbare Fortschritte = höhere Kundenbindung
  • -
  • Weniger „Was war das nochmal?“-Rückfragen
  • -
  • Eigenes Profil = Sichtbarkeit für Neukunden
  • -
  • Kostenfreier Mehrwert, den Sie weitergeben können
  • -
-
-
- - -
-
Was Banyaro bietet
-
-
-
Übungsbibliothek
-
Strukturierte Anleitungen mit Schritten und Hilfestellungen
-
-
-
Trainingspläne
-
Welpe, Grunderziehung, Leinenführung, Rückruf u. v. m.
-
-
-
Trainings-Tagebuch
-
Halter dokumentieren Fortschritt & Stimmung
-
-
-
Rassen-Wiki
-
Über 900 Rassen mit Charakter- & Erziehungs-Profilen
-
-
-
Erste Hilfe
-
Notfall-Anleitungen — wichtig für Outdoor-Training
-
-
-
Gassi & Treffen
-
Routen, Treffpunkte, hundefreundliche Orte
-
-
-
- - -
-
-
Werden Sie Banyaro-Partnerschule
-
    -
  • Eigenes Schul-Profil in der App — auf der Karte verlinkt
  • -
  • QR-Code-Aufsteller für Theorieraum & Anmeldung (kostenfrei)
  • -
  • Empfehlung an Ihre Kunden über unseren Halter-Newsletter
  • -
  • Affiliate-Beteiligung sobald die App monetarisiert wird
  • -
-
-
-
[QR-Code
banyaro.app]
-
Direkt ausprobieren
-
-
- - -
-
In 5 Minuten ausprobieren
-
- Banyaro ist eine Web-App — keine Installation aus dem App-Store nötig. - Öffnen Sie einfach banyaro.app im Browser, legen Sie einen Testaccount an - und schauen Sie sich besonders die Übungsbibliothek und Trainingspläne aus Sicht Ihrer Kunden an. - Bei Interesse an einer Partnerschaft genügt eine kurze E-Mail — ich melde mich persönlich zurück. -
-
- - - - -
- - diff --git a/promotion/banyaro_tieraerzte.html b/promotion/banyaro_tieraerzte.html deleted file mode 100644 index e7be51b..0000000 --- a/promotion/banyaro_tieraerzte.html +++ /dev/null @@ -1,407 +0,0 @@ - - - - -Banyaro für Tierärzte - - - -
- - -
-
-
Banyaro
-
Die App für ein gesundes Hundeleben
-
-
-
Für Tierärztinnen & Tierärzte
-
banyaro.app
-
-
- - -
Eine digitale Gesundheitsakte, die Ihre Patientenbesitzer wirklich nutzen.
-
- Banyaro unterstützt Hundehalter dabei, Symptome, Medikationen, Impftermine und Gewichtsverläufe konsequent zu dokumentieren — und versorgt sie zusätzlich mit fundiertem Wissen zu Erster Hilfe, Giftködern, Rassen und Ernährung. Für Sie als Tierarzt bedeutet das: besser informierte Halter, genauere Anamnesen, weniger Rückfragen. -
- - -
-
-
Zielgruppe
-
    -
  • Engagierte Hundehalter ab ca. 30 Jahren
  • -
  • Familien mit Ersthund
  • -
  • Halter chronisch oder mehrfach erkrankter Tiere
  • -
  • Sicherheitsbewusste Gassigeher (Giftköder-Karte)
  • -
  • Züchter & Mehrhundehaushalte
  • -
-
-
-
Nutzen für Ihre Praxis
-
    -
  • Strukturiertere Anamnesen durch geführte Halter
  • -
  • Vollständige Impf- und Medikationshistorie
  • -
  • Frühere Vorstellung dank Symptom-Tracking
  • -
  • Entlastung bei Standardfragen (Erste Hilfe, Gift)
  • -
  • Mehrwert für Ihre Klienten — kostenlos
  • -
-
-
- - -
-
Was Banyaro bietet
-
-
-
Gesundheitsakte
-
Impfungen, Medikamente, Befunde, Gewicht — exportierbar
-
-
-
Erste-Hilfe-Bereich
-
11 Notfall-Anleitungen, fachlich geprüft
-
-
-
Giftköder-Karte
-
Community-gepflegte Warnkarte mit Echtzeit-Meldungen
-
-
-
Rassen-Wiki
-
Über 900 Rassen mit Gesundheits-Profilen
-
-
-
Trainingsbereich
-
Übungsbibliothek & strukturierte Trainingspläne
-
-
-
Gassi-Funktionen
-
Routen, Treffpunkte, hundefreundliche Orte
-
-
-
- - -
-
-
Werden Sie Banyaro-Partnerpraxis
-
    -
  • Eigenes Praxisprofil in der App — verlinkt aus der Karte
  • -
  • QR-Code-Aufsteller fürs Wartezimmer (kostenfrei zugesendet)
  • -
  • Erwähnung in unserem Halter-Newsletter als Partnerpraxis
  • -
  • Affiliate-Beteiligung sobald die App monetarisiert wird
  • -
-
-
-
[QR-Code
banyaro.app]
-
Direkt ausprobieren
-
-
- - -
-
In 5 Minuten ausprobieren
-
- Banyaro ist eine Web-App — keine Installation aus dem App-Store nötig. - Einfach im Browser banyaro.app öffnen, einen Testaccount anlegen und die Funktionen aus - Sicht Ihrer Patientenbesitzer erkunden. Bei Interesse an einer Partnerschaft genügt eine kurze E-Mail — - ich melde mich persönlich zurück. -
-
- - - - -
- -