diff --git a/backend/routes/diary.py b/backend/routes/diary.py index 8e59465..e99b161 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -1,35 +1,40 @@ """BAN YARO — Tagebuch Routes""" -import os, uuid, json +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 import ki as KI +import httpx router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") class DiaryCreate(BaseModel): - datum: Optional[str] = None # ISO date, default heute - typ: str = "eintrag" - titel: Optional[str] = None - text: Optional[str] = None - tags: Optional[list] = None - gps_lat: Optional[float] = None - gps_lon: Optional[float] = None - is_milestone: bool = False - dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary + datum: Optional[str] = None # ISO date, default heute + typ: str = "eintrag" + titel: Optional[str] = None + text: Optional[str] = None + tags: Optional[list] = None + gps_lat: Optional[float] = None + gps_lon: Optional[float] = None + location_name: Optional[str] = None + is_milestone: bool = False + dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary class DiaryUpdate(BaseModel): - titel: Optional[str] = None - text: Optional[str] = None - tags: Optional[list] = None - is_milestone: Optional[bool] = None - dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen + titel: Optional[str] = None + text: Optional[str] = None + tags: Optional[list] = None + gps_lat: Optional[float] = None + gps_lon: Optional[float] = None + location_name: Optional[str] = None + is_milestone: Optional[bool] = None + dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen def _own_dog(dog_id: int, user_id: int, conn): @@ -90,26 +95,28 @@ def _entry_dict(row, dog_ids_map: dict) -> dict: @router.get("/{dog_id}/diary") async def list_diary(dog_id: int, limit: int = 20, offset: int = 0, - q: Optional[str] = None, + q: Optional[str] = None, milestone: int = 0, user=Depends(get_current_user)): with db() as conn: _own_dog(dog_id, user["id"], conn) + extra = "AND (d.is_milestone=1 OR d.typ='meilenstein')" if milestone else "" if q: pattern = f"%{q}%" rows = conn.execute( - """SELECT DISTINCT d.* FROM diary d + f"""SELECT DISTINCT d.* FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id = d.id WHERE (d.dog_id = ? OR dd.dog_id = ?) AND (d.titel LIKE ? OR d.text LIKE ? OR d.tags LIKE ?) + {extra} ORDER BY d.datum DESC, d.created_at DESC LIMIT ? OFFSET ?""", (dog_id, dog_id, pattern, pattern, pattern, limit, offset) ).fetchall() else: rows = conn.execute( - """SELECT DISTINCT d.* FROM diary d + f"""SELECT DISTINCT d.* FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id = d.id - WHERE d.dog_id = ? OR dd.dog_id = ? + WHERE (d.dog_id = ? OR dd.dog_id = ?) {extra} ORDER BY d.datum DESC, d.created_at DESC LIMIT ? OFFSET ?""", (dog_id, dog_id, limit, offset) @@ -139,12 +146,12 @@ async def create_diary(dog_id: int, data: DiaryCreate, conn.execute( """INSERT INTO diary - (dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone) + (dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, location_name, is_milestone) VALUES (?, COALESCE(?, date('now')), - ?,?,?,?,?,?,?)""", + ?,?,?,?,?,?,?,?)""", (dog_id, data.datum, data.typ, data.titel, data.text, - json.dumps(tags), data.gps_lat, data.gps_lon, int(data.is_milestone)) + json.dumps(tags), data.gps_lat, data.gps_lon, data.location_name, int(data.is_milestone)) ) entry = conn.execute( "SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1", @@ -156,6 +163,133 @@ async def create_diary(dog_id: int, data: DiaryCreate, return _entry_dict(entry, dogs_map) +def _haversine_km(lat1, lon1, lat2, lon2) -> float: + R = 6371 + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = (math.sin(dlat / 2) ** 2 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) + * math.sin(dlon / 2) ** 2) + return R * 2 * math.asin(math.sqrt(a)) + + +@router.get("/{dog_id}/diary/nearby") +async def nearby_places(dog_id: int, lat: float, lon: float, + user=Depends(get_current_user)): + results = [] + + # 1. User-eigene Places + with db() as conn: + _own_dog(dog_id, user["id"], conn) + places = conn.execute( + "SELECT name, typ, lat, lon FROM places WHERE user_id=? AND lat IS NOT NULL", + (user["id"],) + ).fetchall() + for p in places: + km = _haversine_km(lat, lon, p["lat"], p["lon"]) + if km <= 5: + results.append({"name": p["name"], "type": p["typ"] or "place", + "lat": p["lat"], "lon": p["lon"], + "distance_m": int(km * 1000), "source": "places"}) + + # 2. Gecachte OSM-POIs (nur wenn vorhanden) + osm = conn.execute( + "SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''" + ).fetchall() + for p in osm: + km = _haversine_km(lat, lon, p["lat"], p["lon"]) + if km <= 2: + results.append({"name": p["name"], "type": p["type"], + "lat": p["lat"], "lon": p["lon"], + "distance_m": int(km * 1000), "source": "osm"}) + + async with httpx.AsyncClient(timeout=6) as client: + # 3. Overpass: benannte POIs — 800m, bei leerer Antwort auf 2000m erweitern + try: + 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;' + ) + overpass_q = _overpass_q(800) + ov = await client.post( + "https://overpass-api.de/api/interpreter", + data={"data": overpass_q}, + headers={"User-Agent": "BanYaro/1.0"}, + ) + elements = ov.json().get("elements", []) if ov.status_code == 200 else [] + # Kein Ergebnis → Radius auf 2 km erweitern + 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 [] + if True: + for el in elements: + n = el.get("tags", {}).get("name") + if not n: + continue + 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, + "lat": elat, "lon": elon, + "distance_m": int(km * 1000), "source": "overpass"}) + except Exception: + pass + + # 4. Nominatim Reverse-Geocode als Adress-Fallback + try: + r = await client.get( + "https://nominatim.openstreetmap.org/reverse", + params={"lat": lat, "lon": lon, "format": "json", + "zoom": 16, "namedetails": 1, "addressdetails": 1}, + headers={"User-Agent": "BanYaro/1.0"}, + ) + if r.status_code == 200: + d = r.json() + # namedetails kann None sein wenn kein benanntes Feature vorhanden + name = (d.get("name") or + (d.get("namedetails") or {}).get("name")) + if not name: + addr = d.get("address", {}) + name = (addr.get("village") or addr.get("hamlet") or + addr.get("town") or addr.get("city") or + addr.get("suburb") or + d.get("display_name", "").split(",")[0].strip()) + if name: + results.append({"name": name, "type": "address", + "lat": lat, "lon": lon, + "distance_m": 0, "source": "nominatim"}) + except Exception: + pass + + seen, unique = set(), [] + for r in sorted(results, key=lambda x: x["distance_m"]): + key = r["name"].lower() + if key not in seen: + seen.add(key); unique.append(r) + if len(unique) >= 12: + break + return unique + + @router.get("/{dog_id}/diary/{entry_id}") async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)): with db() as conn: @@ -189,13 +323,13 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate, if not exists: raise HTTPException(404, "Eintrag nicht gefunden.") - # Felder updaten - fields = {k: v for k, v in data.model_dump(exclude={"dog_ids"}).items() - if v is not None} - if "tags" in fields: + # Felder updaten — location_name/gps_* dürfen explizit auf None gesetzt werden + raw = data.model_dump(exclude={"dog_ids"}) + NULLABLE = {"location_name", "gps_lat", "gps_lon"} + fields = {k: v for k, v in raw.items() if v is not None or k in NULLABLE} + if "tags" in fields and fields["tags"] is not None: fields["tags"] = json.dumps(fields["tags"]) if fields: - # primary dog_id für SET-Clause ermitteln (der Eintrag bleibt dem Erstell-Hund) set_clause = ", ".join(f"{k}=?" for k in fields) conn.execute( f"UPDATE diary SET {set_clause} WHERE id=?", @@ -241,6 +375,17 @@ async def upload_media(dog_id: int, entry_id: int, if not entry: raise HTTPException(404, "Eintrag nicht gefunden.") + ALLOWED = { + "image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif", + "video/mp4", "video/quicktime", "video/webm", "video/x-m4v", + } + ct = file.content_type or "" + if ct not in ALLOWED: + ext_low = os.path.splitext(file.filename or "")[1].lower() + if ext_low not in {".jpg",".jpeg",".png",".gif",".webp",".heic",".heif", + ".mp4",".mov",".webm",".m4v"}: + raise HTTPException(415, "Nur Bilder und Videos erlaubt.") + ext = os.path.splitext(file.filename or "")[1] or ".jpg" filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}" path = os.path.join(MEDIA_DIR, "diary", filename) @@ -249,8 +394,34 @@ async def upload_media(dog_id: int, entry_id: int, with open(path, "wb") as f: f.write(await file.read()) - media_url = f"/media/diary/{filename}" + # Altes Medium von Disk löschen wenn vorhanden with db() as conn: + old = conn.execute("SELECT media_url FROM diary WHERE id=?", (entry_id,)).fetchone() + if old and old["media_url"]: + old_path = os.path.join(MEDIA_DIR, old["media_url"].lstrip("/media/")) + try: os.remove(old_path) + except OSError: pass + media_url = f"/media/diary/{filename}" conn.execute("UPDATE diary SET media_url=? WHERE id=?", (media_url, entry_id)) return {"media_url": media_url} + + +@router.delete("/{dog_id}/diary/{entry_id}/media", status_code=204) +async def delete_media(dog_id: int, entry_id: int, user=Depends(get_current_user)): + with db() as conn: + _own_dog(dog_id, user["id"], conn) + row = conn.execute( + "SELECT media_url FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id) + ).fetchone() + if not row: + raise HTTPException(404, "Eintrag nicht gefunden.") + if row["media_url"]: + path = os.path.join(MEDIA_DIR, row["media_url"].lstrip("/media/")) + try: os.remove(path) + except OSError: pass + conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,)) + + + + return unique diff --git a/backend/routes/import_data.py b/backend/routes/import_data.py index affa418..76c3099 100644 --- a/backend/routes/import_data.py +++ b/backend/routes/import_data.py @@ -17,12 +17,24 @@ MAX_CSV_MB = 10 # HTML → Plaintext # ------------------------------------------------------------------ class _HTMLStripper(HTMLParser): + _SKIP_TAGS = {"video", "audio", "script", "style"} + def __init__(self): super().__init__() self.parts = [] + self._skip = 0 + + def handle_starttag(self, tag, attrs): + if tag.lower() in self._SKIP_TAGS: + self._skip += 1 + + def handle_endtag(self, tag): + if tag.lower() in self._SKIP_TAGS and self._skip > 0: + self._skip -= 1 def handle_data(self, data): - self.parts.append(data) + if not self._skip: + self.parts.append(data) def handle_entityref(self, name): if name == "nbsp": diff --git a/backend/static/css/components.css b/backend/static/css/components.css index ef6e17c..e0e00f6 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -53,6 +53,41 @@ border-color: var(--c-surface-3); } +.btn-active { + background: color-mix(in srgb, var(--c-primary) 15%, var(--c-surface)) !important; + border-color: var(--c-primary) !important; + color: var(--c-primary-dark) !important; +} + +/* Meilenstein-Toggle im Formular */ +.diary-milestone-toggle { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-3) var(--space-4); + border: 1.5px dashed var(--c-border); + border-radius: var(--radius-md); + background: transparent; + color: var(--c-text-secondary); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + cursor: pointer; + transition: all var(--transition-fast); +} +.diary-milestone-toggle:hover { + border-color: #d4a017; + color: #8a6400; +} +.diary-milestone-toggle--active { + border-style: solid; + border-color: #d4a017; + background: color-mix(in srgb, #d4a017 10%, transparent); + color: #8a6400; + font-weight: var(--weight-semibold); +} +.diary-milestone-toggle .ph-icon { font-size: 1.2rem; } + .btn-ghost { background: transparent; color: var(--c-text-secondary); @@ -402,6 +437,11 @@ color: var(--c-text-secondary); } +/* iOS Safari: font-size < 16px triggert Auto-Zoom beim Fokus — muss alle Klassen überschreiben */ +input, select, textarea { + font-size: var(--text-base) !important; +} + .form-control { width: 100%; padding: var(--space-3) var(--space-4); @@ -624,6 +664,12 @@ textarea.form-control { /* ------------------------------------------------------------ 8. MODAL ------------------------------------------------------------ */ +/* Verhindert Body-Scroll wenn Modal offen — kein Layout-Sprung */ +html.modal-open { + overflow: hidden; + overscroll-behavior: none; +} + .modal-overlay { position: fixed; inset: 0; @@ -635,7 +681,8 @@ textarea.form-control { padding: var(--space-4); backdrop-filter: blur(2px); animation: overlay-in var(--transition-normal) ease; - touch-action: manipulation; + touch-action: none; + overscroll-behavior: none; } @media (min-width: 768px) { .modal-overlay { align-items: center; } @@ -702,9 +749,11 @@ textarea.form-control { font-weight: var(--weight-semibold); } .modal-body { - padding: var(--space-6); - overflow-y: auto; - flex: 1; /* füllt den Raum zwischen Header und Footer */ + padding: var(--space-6); + overflow-y: auto; + flex: 1; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; scrollbar-width: thin; scrollbar-color: var(--c-primary) var(--c-surface); } @@ -977,7 +1026,7 @@ textarea.form-control { letter-spacing: 0.03em; } -/* Foto oben */ +/* Foto / Video oben */ .diary-card-photo { width: 100%; height: 180px; @@ -989,6 +1038,43 @@ textarea.form-control { object-fit: cover; display: block; } +.diary-media-picker { + display: flex; + gap: var(--space-2); + margin-top: var(--space-1); +} +.diary-media-pick-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); + padding: var(--space-3) var(--space-2); + border: 1.5px solid var(--c-border); + border-radius: var(--radius-md); + background: var(--c-surface); + color: var(--c-text-secondary); + font-size: var(--text-sm); + cursor: pointer; + transition: border-color var(--transition-fast), color var(--transition-fast); +} +.diary-media-pick-btn:hover, +.diary-media-pick-btn:active { + border-color: var(--c-primary); + color: var(--c-primary); +} +.diary-media-pick-btn .ph-icon { font-size: 1.5rem; } + +.diary-card-video-thumb { + width: 100%; + height: 100%; + background: var(--c-surface-2); + display: flex; + align-items: center; + justify-content: center; + color: var(--c-primary); + font-size: 3rem; +} /* Card Body */ .diary-card-body { @@ -1022,6 +1108,79 @@ textarea.form-control { margin-bottom: var(--space-1); } +/* Ort-Zeile in Karte */ +.diary-card-location { + display: flex; + align-items: center; + 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; } + +/* Ort in Detail-Ansicht */ +.diary-detail-location { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--c-primary); + margin-bottom: var(--space-3); +} + +/* Koordinaten-Zeile im Formular */ +.diary-coords-row { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--c-surface-2); + border-radius: var(--radius-md); + font-size: var(--text-sm); + color: var(--c-text-secondary); + font-variant-numeric: tabular-nums; +} +.diary-coords-row .ph-icon { flex-shrink: 0; } + +/* Location-Chip im Formular */ +.diary-location-chip { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: color-mix(in srgb, var(--c-primary) 10%, transparent); + border: 1.5px solid var(--c-primary); + border-radius: var(--radius-full); + font-size: var(--text-sm); + color: var(--c-primary-dark); +} +.diary-location-chip span { flex: 1; } +.diary-location-chip button { + background: none; border: none; padding: 0; cursor: pointer; + color: var(--c-primary); display: flex; +} + +/* Vorschlagsliste */ +.diary-location-suggestion { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) var(--space-3); + background: var(--c-surface); + border: 1px solid var(--c-border-light); + border-radius: var(--radius-md); + margin-bottom: var(--space-1); + cursor: pointer; + text-align: left; + font-size: var(--text-sm); + color: var(--c-text); +} +.diary-location-suggestion span { flex: 1; } +.diary-location-suggestion small { color: var(--c-text-muted); flex-shrink: 0; } +.diary-location-suggestion:hover { border-color: var(--c-primary); color: var(--c-primary); } + /* Text-Vorschau */ .diary-card-text { font-size: var(--text-sm); diff --git a/backend/static/icons/icon-192-any.png b/backend/static/icons/icon-192-any.png new file mode 100644 index 0000000..bd474d7 Binary files /dev/null and b/backend/static/icons/icon-192-any.png differ diff --git a/backend/static/icons/icon-512-any.png b/backend/static/icons/icon-512-any.png new file mode 100644 index 0000000..0ee2773 Binary files /dev/null and b/backend/static/icons/icon-512-any.png differ diff --git a/backend/static/index.html b/backend/static/index.html index 8f225c4..d92526e 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -46,7 +46,7 @@ Tagebuch Entdecken @@ -381,7 +381,7 @@