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 @@
-
-
-
+
-