Sprint 12+13: Tagebuch Day-One-Redesign, Notiz-Feature, Icon-Fixes, SW by-v405

Tagebuch:
- Day-One-Listenansicht: Wochentag + Tageszahl + Meta-Zeile (Zeit/Ort/Wetter)
- 4 Ansichten: Liste, Medien-Mosaik, Kalender (mit Sprungbuttons), Karte (GPS-Marker)
- Detail-Ansicht inline im Content-Bereich (kein Fullscreen-Overlay mehr)
- Hero-Bild vollständig sichtbar (object-fit:contain), Lightbox mit Safe-Area
- 2-Spalten-Layout Desktop: Text + Leaflet-Karte + POI-Liste
- EXIF-GPS-Extraktion bei Foto-Upload, historisches Wetter via Archive-API
- NoteStation-Import: Fotos in diary_media (80 Einträge migriert, 94 Medien)
- Stats-Endpoints: /diary/stats, /diary/calendar, /diary/locations

Notiz-Feature:
- Generische notes-Tabelle (parent_type + parent_id + meta_json)
- 📝-Button in 8 Bereichen, Notizblock-Seite mit KI-Analyse
- KI-Toggle in Einstellungen, notes_ki_enabled in User-Profil

Icons & Design:
- fill:currentColor Fix für welcome/onboarding/friends.js
- --c-icon Variable, --c-text-muted Dark Mode aufgehellt
- 15+ neue Phosphor-Icons aus lokaler Kopie
- CSS Network-First im SW, Cache-Control-Middleware

Infrastruktur:
- Wiki-Anreicherungs-Scheduler-Jobs entfernt (abgeschlossen)
- auth.py: notes_ki_enabled + is_social_media im User-Response
This commit is contained in:
rene 2026-04-25 20:44:46 +02:00
parent 95f91fdc00
commit 553e9e7854
35 changed files with 4558 additions and 370 deletions

View file

@ -0,0 +1 @@
{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339}

View file

@ -46,6 +46,50 @@ 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) ## Implementierungsstand (aktuell: 2026-04-25, SW by-v370, APP_VER 355)
### Sprint 11 (2026-04-25) ✅ ### Sprint 11 (2026-04-25) ✅
@ -222,7 +266,7 @@ Maps: Leaflet.js + OpenStreetMap (kostenlos, kein Google-Lock)
#### 1.2 Gesundheit & Impfpass #### 1.2 Gesundheit & Impfpass
- [ ] Impfungen, Entwurmungen, Tierarztbesuche digital - [ ] Impfungen, Entwurmungen, Tierarztbesuche digital
- [ ] Medikamenten-Reminder (Push Notification) - [ ] Medikamenten-Reminder (Push Notification)
- [ ] Gewichtsverlauf-Chart - [x] Gewichtsverlauf-Chart ✅
- [ ] Einfacher Symptom-Checker (KI-gestützt, Triage: beobachten/Tierarzt/Notfall) - [ ] Einfacher Symptom-Checker (KI-gestützt, Triage: beobachten/Tierarzt/Notfall)
#### 1.3 Giftköder-Alarm #### 1.3 Giftköder-Alarm

View file

@ -87,7 +87,7 @@ def get_current_user(
user_id = int(payload["sub"]) user_id = int(payload["sub"])
with db() as conn: with db() as conn:
row = conn.execute( row = conn.execute(
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason FROM users WHERE id=?", "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled FROM users WHERE id=?",
(user_id,) (user_id,)
).fetchone() ).fetchone()

View file

@ -534,6 +534,13 @@ def _migrate(conn_factory):
("pflege_tipps", "fell_pflege_art", "TEXT"), ("pflege_tipps", "fell_pflege_art", "TEXT"),
# Wiki-Foto-Einreichungen: Bildrechte-Bestätigung # Wiki-Foto-Einreichungen: Bildrechte-Bestätigung
("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"), ("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: with conn_factory() as conn:
for table, column, col_type in migrations: for table, column, col_type in migrations:
@ -1131,3 +1138,22 @@ def _migrate(conn_factory):
CREATE INDEX IF NOT EXISTS idx_ki_daily_source ON ki_daily_calls(date, source); CREATE INDEX IF NOT EXISTS idx_ki_daily_source ON ki_daily_calls(date, source);
""") """)
logger.info("Migration: ki_daily_calls.source bereit.") 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.")

View file

@ -80,6 +80,24 @@ class _UploadSizeMiddleware(BaseHTTPMiddleware):
app.add_middleware(_UploadSizeMiddleware) 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) # API-Router registrieren (werden nach und nach hinzugefügt)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -122,6 +140,7 @@ from routes.praise import router as praise_router
from routes.weather import router as weather_router from routes.weather import router as weather_router
from routes.social import router as social_router from routes.social import router as social_router
from routes.moderation import router as moderation_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(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -163,6 +182,7 @@ app.include_router(achievements_router, prefix="/api/achievements", tags=
app.include_router(training_router, prefix="/api/training", tags=["Training"]) app.include_router(training_router, prefix="/api/training", tags=["Training"])
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"]) app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"]) app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
app.include_router(notes_router, prefix="/api/notes", tags=["Notes"])
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -117,6 +117,36 @@ def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]:
pass 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]: def convert_media(data: bytes, filename: str) -> Tuple[bytes, str]:
"""Convert HEIC→JPEG and MOV/AVI/M4V→MP4; pass everything else through.""" """Convert HEIC→JPEG and MOV/AVI/M4V→MP4; pass everything else through."""
ext = os.path.splitext(filename or "")[1].lower() ext = os.path.splitext(filename or "")[1].lower()

View file

@ -1,14 +1,17 @@
"""BAN YARO — Tagebuch Routes""" """BAN YARO — Tagebuch Routes"""
import os, uuid, json, math import os, uuid, json, math, logging
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user, require_admin
import ki as KI import ki as KI
import httpx import httpx
from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload 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__)
router = APIRouter() router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@ -142,6 +145,69 @@ def _entry_dict(row, dog_ids_map: dict, media_map: dict = None) -> dict:
return e 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") @router.get("/{dog_id}/diary")
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0, async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
q: Optional[str] = None, milestone: int = 0, q: Optional[str] = None, milestone: int = 0,
@ -226,10 +292,95 @@ async def create_diary(dog_id: int, data: DiaryCreate,
_set_dog_ids(conn, entry["id"], all_dogs) _set_dog_ids(conn, entry["id"], all_dogs)
dogs_map = _fetch_dog_ids(conn, [entry["id"]]) dogs_map = _fetch_dog_ids(conn, [entry["id"]])
media_map = _fetch_media_items(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) 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: def _haversine_km(lat1, lon1, lat2, lon2) -> float:
R = 6371 R = 6371
dlat = math.radians(lat2 - lat1) dlat = math.radians(lat2 - lat1)
@ -508,6 +659,11 @@ async def upload_media(dog_id: int, entry_id: int,
media_url = f"/media/diary/{filename}" 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: with db() as conn:
# sort_order = nächste freie Position # sort_order = nächste freie Position
max_order = conn.execute( max_order = conn.execute(
@ -525,8 +681,38 @@ async def upload_media(dog_id: int, entry_id: int,
(entry_id,) (entry_id,)
).fetchone()["id"] ).fetchone()["id"]
return {"id": new_id, "url": media_url, "media_type": media_type, # 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,
"sort_order": max_order + 1, "is_cover": is_cover} "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) @router.delete("/{dog_id}/diary/{entry_id}/media/{media_id}", status_code=204)
@ -587,3 +773,55 @@ 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=0 WHERE diary_id=?", (entry_id,))
conn.execute("UPDATE diary_media SET is_cover=1 WHERE id=?", (media_id,)) conn.execute("UPDATE diary_media SET is_cover=1 WHERE id=?", (media_id,))
return {"ok": True} 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}

View file

@ -155,9 +155,9 @@ async def import_notestation(
(entry_id, dog_id), (entry_id, dog_id),
) )
# Erstes Bild speichern # Anhänge in diary_media speichern (statt veraltetem media_url-Feld)
attachments = note.get("attachment") or {} attachments = note.get("attachment") or {}
media_url = None first = True
for att in attachments.values(): for att in attachments.values():
md5 = att.get("md5", "") md5 = att.get("md5", "")
mime = att.get("type", "image/jpeg") mime = att.get("type", "image/jpeg")
@ -165,13 +165,11 @@ async def import_notestation(
continue continue
media_url = _save_image_from_zip(zf, md5, mime) media_url = _save_image_from_zip(zf, md5, mime)
if media_url: if media_url:
break conn.execute(
"INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)",
if media_url: (entry_id, media_url, "image", 0 if first else 1, 1 if first else 0),
conn.execute( )
"UPDATE diary SET media_url=? WHERE id=?", first = False
(media_url, entry_id),
)
imported += 1 imported += 1

263
backend/routes/notes.py Normal file
View file

@ -0,0 +1,263 @@
"""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

View file

@ -25,6 +25,7 @@ class ProfileUpdate(BaseModel):
erfahrung: Optional[str] = None erfahrung: Optional[str] = None
social_link: Optional[str] = None social_link: Optional[str] = None
profil_sichtbarkeit: Optional[str] = None profil_sichtbarkeit: Optional[str] = None
notes_ki_enabled: Optional[int] = None
def _load_user(user_id: int) -> dict: def _load_user(user_id: int) -> dict:

View file

@ -90,14 +90,6 @@ def start():
id="seed_wikidata_startup", id="seed_wikidata_startup",
replace_existing=True, 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 # Jeden Montag 09:00 — Wöchentlicher Fortschritts-Lober
_scheduler.add_job( _scheduler.add_job(
_job_weekly_praise, _job_weekly_praise,
@ -114,16 +106,8 @@ def start():
replace_existing=True, replace_existing=True,
misfire_grace_time=1800, 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() _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, Wiki-KI-Anreicherung 02:30.") 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.")
def stop(): def stop():
@ -629,35 +613,6 @@ 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 # Hilfsfunktion: Lob-Text für einen Hund generieren
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -815,12 +770,6 @@ async def _job_status_report():
metrics = {} metrics = {}
try: try:
with db() as conn: 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 # Züchter
try: try:
metrics["zuchter_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0").fetchone()[0] metrics["zuchter_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0").fetchone()[0]
@ -848,16 +797,6 @@ async def _job_status_report():
logger.error(f"Status-Report: DB-Fehler: {e}") logger.error(f"Status-Report: DB-Fehler: {e}")
return 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-Log-Tabelle ---
job_labels = { job_labels = {
"health_reminders": "Gesundheits-Erinnerungen", "health_reminders": "Gesundheits-Erinnerungen",
@ -865,8 +804,6 @@ async def _job_status_report():
"weather_alert": "Wetter-Alert", "weather_alert": "Wetter-Alert",
"milestone_check": "Meilenstein-Check", "milestone_check": "Meilenstein-Check",
"import_events": "Event-Import (VDH)", "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_breeds_startup": "Rassen-Seed (TheDogAPI)",
"seed_wikidata_startup":"Rassen-Seed (Wikidata)", "seed_wikidata_startup":"Rassen-Seed (Wikidata)",
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)", "weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
@ -899,18 +836,6 @@ async def _job_status_report():
<div style="opacity:.88;font-size:13px">{now_str} Uhr</div> <div style="opacity:.88;font-size:13px">{now_str} Uhr</div>
</div> </div>
<!-- Wiki-Fortschritt -->
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Wiki KI-Anreicherung</div>
<div style="font-family:monospace;font-size:13px;background:#fdf6ef;border-radius:8px;padding:12px 14px;line-height:1.8">
<span style="color:#555">{progress_bar}</span> <strong>{pct}%</strong><br>
Angereichert: <strong>{enriched}</strong> / {total}<br>
Verbleibend: <strong>{remaining}</strong> Rassen (~{nights_left} Nächte)<br>
📷 Mit lokalem Foto: <strong>{metrics['rassen_mit_foto']}</strong><br>
📝 Mit Beschreibung: <strong>{metrics['rassen_mit_desc']}</strong>
</div>
</div>
<!-- Scheduler-Status --> <!-- Scheduler-Status -->
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc"> <div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Scheduler-Jobs</div> <div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Scheduler-Jobs</div>
@ -947,13 +872,6 @@ async def _job_status_report():
plain = f"""Ban Yaro Status-Report — {now_str} 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 === === Scheduler-Jobs ===
{job_rows_txt} {job_rows_txt}
=== Community === === Community ===

View file

@ -963,82 +963,326 @@ html.modal-open {
} }
/* ------------------------------------------------------------ /* ------------------------------------------------------------
12. TAGEBUCH 12. TAGEBUCH Day One Style
------------------------------------------------------------ */ ------------------------------------------------------------ */
/* Monats-Trennlinie */ /* Stats-Leiste */
.diary-month-header { .diary-stats-bar {
font-size: var(--text-sm); display: flex;
font-weight: var(--weight-semibold); align-items: center;
color: var(--c-text-secondary); justify-content: space-between;
text-transform: uppercase; gap: 0;
letter-spacing: 0.06em; padding: 8px 12px;
padding: var(--space-4) 0 var(--space-2); border-bottom: 1px solid var(--c-divider, var(--c-border));
border-bottom: 1px solid var(--c-border); background: var(--c-surface);
margin-bottom: var(--space-3); flex-shrink: 0;
} }
.diary-month-header:first-child { .diary-stats-numbers {
padding-top: 0; 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;
} }
/* Eintragskarte */ /* 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 */
.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;
}
.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);
}
/* Eintragskarte — Day One Row-Style */
.diary-card { .diary-card {
background: var(--c-surface); display: flex;
border: 1px solid var(--c-border); align-items: flex-start;
border-radius: var(--radius-lg); gap: 12px;
margin-bottom: var(--space-3); padding: 14px 16px;
overflow: hidden; background: transparent;
cursor: pointer; border: none;
transition: box-shadow var(--transition-fast), border-bottom: 1px solid var(--c-divider, var(--c-border));
transform var(--transition-fast); border-radius: 0;
box-shadow: var(--shadow-xs); margin-bottom: 0;
overflow: visible;
cursor: pointer;
transition: background var(--transition-fast);
box-shadow: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.diary-card:last-child {
border-bottom: none;
}
.diary-card:hover { .diary-card:hover {
box-shadow: var(--shadow-md); background: rgba(0,0,0,0.025);
transform: translateY(-1px); box-shadow: none;
transform: none;
} }
.diary-card:active { .diary-card:active {
transform: scale(0.99); 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;
} }
/* Meilenstein-Hervorhebung */ /* Meilenstein-Hervorhebung */
.diary-card--milestone { .diary-card--milestone {
border-color: #d4a017; background: color-mix(in srgb, #d4a017 4%, transparent);
border-width: 2px; }
background: linear-gradient( .diary-card--milestone .diary-card-daynum {
135deg, color: #b8860b;
var(--c-surface) 0%,
color-mix(in srgb, #d4a017 8%, var(--c-surface)) 100%
);
} }
/* Meilenstein-Badge innerhalb der Karte */ /* Meilenstein-Badge innerhalb der Karte */
.diary-card-milestone-badge { .diary-card-milestone-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
background: color-mix(in srgb, #d4a017 15%, transparent); background: color-mix(in srgb, #d4a017 15%, transparent);
color: #8a6400; color: #8a6400;
font-weight: 600; font-weight: 600;
font-size: var(--text-xs); font-size: var(--text-xs);
padding: 2px var(--space-2); padding: 2px var(--space-2);
border-radius: var(--radius-full); border-radius: var(--radius-full);
margin-bottom: var(--space-2); margin-bottom: 4px;
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
/* Foto / Video oben */ /* Foto / Thumbnail rechts — 72×72px */
.diary-card-photo { .diary-card-photo {
width: 100%; width: 72px;
height: 180px; height: 72px;
overflow: hidden; flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
position: relative;
margin-top: 2px;
} }
.diary-card-photo img { .diary-card-photo img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
} }
.diary-media-picker { .diary-media-picker {
display: flex; display: flex;
@ -1165,7 +1409,7 @@ html.modal-open {
border-radius: 50%; border-radius: 50%;
border: none; border: none;
background: rgba(0,0,0,.50); background: rgba(0,0,0,.50);
color: #9ca3af; color: rgba(255,255,255,.55);
font-size: 14px; font-size: 14px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@ -1177,7 +1421,7 @@ html.modal-open {
transition: color .15s, background .15s; transition: color .15s, background .15s;
} }
.diary-cover-btn--active { .diary-cover-btn--active {
color: #f5c518; color: var(--c-amber);
background: rgba(0,0,0,.65); background: rgba(0,0,0,.65);
} }
.diary-cover-btn--form { .diary-cover-btn--form {
@ -1185,48 +1429,46 @@ html.modal-open {
left: var(--space-1); left: var(--space-1);
} }
/* Card Body */ /* Card Body — mittlere Spalte */
.diary-card-body { .diary-card-body {
padding: var(--space-3) var(--space-4); flex: 1;
min-width: 0;
padding: 0;
} }
/* Meta-Zeile: Typ + Datum */ /* Titel in Karte */
.diary-card-meta {
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 { .diary-card-title {
font-size: var(--text-base); font-size: 15px;
font-weight: var(--weight-semibold); font-weight: 700;
color: var(--c-text); color: var(--c-text);
margin-bottom: var(--space-1); 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 */
.diary-card-meta {
display: none;
}
.diary-card-type { display: none; }
.diary-card-date { display: none; }
/* Ort-Zeile in Karte */ /* Ort-Zeile in Karte */
.diary-card-location { .diary-card-location {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-1); gap: 4px;
font-size: var(--text-sm); font-size: 12px;
color: var(--c-primary); color: var(--c-text-muted);
margin: 0 0 var(--space-1); margin: 0 0 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.diary-card-location .ph-icon { flex-shrink: 0; } .diary-card-location .ph-icon { flex-shrink: 0; width: 12px; height: 12px; }
/* Ort in Detail-Ansicht */ /* Ort in Detail-Ansicht */
.diary-detail-location { .diary-detail-location {
@ -1292,12 +1534,12 @@ html.modal-open {
/* Text-Vorschau */ /* Text-Vorschau */
.diary-card-text { .diary-card-text {
font-size: var(--text-sm); font-size: 13px;
color: var(--c-text-secondary); color: var(--c-text-secondary);
line-height: 1.5; line-height: 1.45;
margin: 0 0 var(--space-2); margin: 0 0 4px;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
@ -1310,6 +1552,62 @@ html.modal-open {
margin-top: var(--space-1); 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 */ /* Detail-Ansicht */
.diary-detail-milestone-badge { .diary-detail-milestone-badge {
display: inline-flex; display: inline-flex;
@ -1324,6 +1622,312 @@ html.modal-open {
margin-bottom: var(--space-3); 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-Attribution ausblenden */
.leaflet-control-attribution { display: none !important; } .leaflet-control-attribution { display: none !important; }

View file

@ -44,6 +44,7 @@
--c-warning: #D4923A; --c-warning: #D4923A;
--c-warning-subtle: #FDF3E3; --c-warning-subtle: #FDF3E3;
--c-amber: #E4A020; /* Goldgelb — "Heute"-Akzent, distinct von Primary */ --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: #4A7A9B;
--c-info-subtle: #E8F2F8; --c-info-subtle: #E8F2F8;
@ -137,8 +138,11 @@
--c-text: #F0EAE0; --c-text: #F0EAE0;
--c-text-secondary: #C0B0A0; --c-text-secondary: #C0B0A0;
--c-text-muted: #806A58; --c-text-muted: #9A8878;
--c-text-inverse: #2A1F14; --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-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); --shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.25);

View file

@ -202,7 +202,7 @@
justify-content: center; justify-content: center;
gap: 3px; gap: 3px;
cursor: pointer; cursor: pointer;
color: var(--c-text-muted); color: var(--c-icon, var(--c-text-secondary));
transition: color var(--transition-fast); transition: color var(--transition-fast);
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
touch-action: manipulation; touch-action: manipulation;

View file

@ -30,6 +30,21 @@
<symbol id="map-trifold" viewBox="0 0 256 256"><path d="M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z"/></symbol> <symbol id="map-trifold" viewBox="0 0 256 256"><path d="M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z"/></symbol>
<symbol id="path" viewBox="0 0 256 256"><path d="M200,168a32.06,32.06,0,0,0-31,24H72a32,32,0,0,1,0-64h96a40,40,0,0,0,0-80H72a8,8,0,0,0,0,16h96a24,24,0,0,1,0,48H72a48,48,0,0,0,0,96h97a32,32,0,1,0,31-40Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,200,216Z"/></symbol> <symbol id="path" viewBox="0 0 256 256"><path d="M200,168a32.06,32.06,0,0,0-31,24H72a32,32,0,0,1,0-64h96a40,40,0,0,0,0-80H72a8,8,0,0,0,0,16h96a24,24,0,0,1,0,48H72a48,48,0,0,0,0,96h97a32,32,0,1,0,31-40Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,200,216Z"/></symbol>
<symbol id="paw-print" viewBox="0 0 256 256"><path d="M212,80a28,28,0,1,0,28,28A28,28,0,0,0,212,80Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,212,120ZM72,108a28,28,0,1,0-28,28A28,28,0,0,0,72,108ZM44,120a12,12,0,1,1,12-12A12,12,0,0,1,44,120ZM92,88A28,28,0,1,0,64,60,28,28,0,0,0,92,88Zm0-40A12,12,0,1,1,80,60,12,12,0,0,1,92,48Zm72,40a28,28,0,1,0-28-28A28,28,0,0,0,164,88Zm0-40a12,12,0,1,1-12,12A12,12,0,0,1,164,48Zm23.12,100.86a35.3,35.3,0,0,1-16.87-21.14,44,44,0,0,0-84.5,0A35.25,35.25,0,0,1,69,148.82,40,40,0,0,0,88,224a39.48,39.48,0,0,0,15.52-3.13,64.09,64.09,0,0,1,48.87,0,40,40,0,0,0,34.73-72ZM168,208a24,24,0,0,1-9.45-1.93,80.14,80.14,0,0,0-61.19,0,24,24,0,0,1-20.71-43.26,51.22,51.22,0,0,0,24.46-30.67,28,28,0,0,1,53.78,0,51.27,51.27,0,0,0,24.53,30.71A24,24,0,0,1,168,208Z"/></symbol> <symbol id="paw-print" viewBox="0 0 256 256"><path d="M212,80a28,28,0,1,0,28,28A28,28,0,0,0,212,80Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,212,120ZM72,108a28,28,0,1,0-28,28A28,28,0,0,0,72,108ZM44,120a12,12,0,1,1,12-12A12,12,0,0,1,44,120ZM92,88A28,28,0,1,0,64,60,28,28,0,0,0,92,88Zm0-40A12,12,0,1,1,80,60,12,12,0,0,1,92,48Zm72,40a28,28,0,1,0-28-28A28,28,0,0,0,164,88Zm0-40a12,12,0,1,1-12,12A12,12,0,0,1,164,48Zm23.12,100.86a35.3,35.3,0,0,1-16.87-21.14,44,44,0,0,0-84.5,0A35.25,35.25,0,0,1,69,148.82,40,40,0,0,0,88,224a39.48,39.48,0,0,0,15.52-3.13,64.09,64.09,0,0,1,48.87,0,40,40,0,0,0,34.73-72ZM168,208a24,24,0,0,1-9.45-1.93,80.14,80.14,0,0,0-61.19,0,24,24,0,0,1-20.71-43.26,51.22,51.22,0,0,0,24.46-30.67,28,28,0,0,1,53.78,0,51.27,51.27,0,0,0,24.53,30.71A24,24,0,0,1,168,208Z"/></symbol>
<symbol id="note-pencil" viewBox="0 0 256 256"><path d="M224,128v80a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32h80a8,8,0,0,1,0,16H48V208H208V128a8,8,0,0,1,16,0Zm5.66-58.34-96,96A8,8,0,0,1,128,168H96a8,8,0,0,1-8-8V128a8,8,0,0,1,2.34-5.66l96-96a8,8,0,0,1,11.32,0l32,32A8,8,0,0,1,229.66,69.66Zm-17-5.66L192,43.31,179.31,56,200,76.69Z"/></symbol>
<symbol id="images" viewBox="0 0 256 256"><path d="M216,40H72A16,16,0,0,0,56,56V72H40A16,16,0,0,0,24,88V200a16,16,0,0,0,16,16H184a16,16,0,0,0,16-16V184h16a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM172,72a12,12,0,1,1-12,12A12,12,0,0,1,172,72Zm12,128H40V88H56v80a16,16,0,0,0,16,16H184Zm32-32H72V120.69l30.34-30.35a8,8,0,0,1,11.32,0L163.31,140,189,114.34a8,8,0,0,1,11.31,0L216,130.07V168Z"/></symbol>
<symbol id="caret-left" viewBox="0 0 256 256"><path d="M163.06,40.61a8,8,0,0,0-8.72,1.73l-80,80a8,8,0,0,0,0,11.32l80,80A8,8,0,0,0,168,208V48A8,8,0,0,0,163.06,40.61Z"/></symbol>
<symbol id="caret-right" viewBox="0 0 256 256"><path d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34Z"/></symbol>
<symbol id="coffee" viewBox="0 0 256 256"><path d="M208,80H32a8,8,0,0,0-8,8v48a96.3,96.3,0,0,0,32.54,72H32a8,8,0,0,0,0,16H208a8,8,0,0,0,0-16H183.46a96.59,96.59,0,0,0,27-40.09A40,40,0,0,0,248,128v-8A40,40,0,0,0,208,80Zm24,48a24,24,0,0,1-17.2,23,95.78,95.78,0,0,0,1.2-15V97.38A24,24,0,0,1,232,120ZM112,56V24a8,8,0,0,1,16,0V56a8,8,0,0,1-16,0Zm32,0V24a8,8,0,0,1,16,0V56a8,8,0,0,1-16,0ZM80,56V24a8,8,0,0,1,16,0V56a8,8,0,0,1-16,0Z"/></symbol>
<symbol id="beer-bottle" viewBox="0 0 256 256"><path d="M245.66,42.34l-32-32a8,8,0,0,0-11.32,11.32l1.48,1.47L148.65,64.51l-38.22,7.65a8.05,8.05,0,0,0-4.09,2.18L23,157.66a24,24,0,0,0,0,33.94L64.4,233a24,24,0,0,0,33.94,0l83.32-83.31a8,8,0,0,0,2.18-4.09l7.65-38.22,41.38-55.17,1.47,1.48a8,8,0,0,0,11.32-11.32ZM81.37,224a7.94,7.94,0,0,1-5.65-2.34L34.34,180.28a8,8,0,0,1,0-11.31L40,163.31,92.69,216,87,221.66A8,8,0,0,1,81.37,224ZM177.6,99.2a7.92,7.92,0,0,0-1.44,3.23l-7.53,37.63L160,148.69,107.31,96l8.63-8.63,37.63-7.53a7.92,7.92,0,0,0,3.23-1.44l58.45-43.84,6.19,6.19Z"/></symbol>
<symbol id="shopping-bag" viewBox="0 0 256 256"><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-88,96A48.05,48.05,0,0,1,80,88a8,8,0,0,1,16,0,32,32,0,0,0,64,0,8,8,0,0,1,16,0A48.05,48.05,0,0,1,128,136Z"/></symbol>
<symbol id="binoculars" viewBox="0 0 256 256"><path d="M237.22,151.9l0-.1a1.42,1.42,0,0,0-.07-.22,48.46,48.46,0,0,0-2.31-5.3L193.27,51.8a8,8,0,0,0-1.67-2.44,32,32,0,0,0-45.26,0A8,8,0,0,0,144,55V80H112V55a8,8,0,0,0-2.34-5.66,32,32,0,0,0-45.26,0,8,8,0,0,0-1.67,2.44L21.2,146.28a48.46,48.46,0,0,0-2.31,5.3,1.72,1.72,0,0,0-.07.21s0,.08,0,.11a48,48,0,0,0,90.32,32.51,47.49,47.49,0,0,0,2.9-16.59V96h32v71.83a47.49,47.49,0,0,0,2.9,16.59,48,48,0,0,0,90.32-32.51Zm-143.15,27a32,32,0,0,1-60.2-21.71l1.81-4.13A32,32,0,0,1,96,167.88V168h0A32,32,0,0,1,94.07,178.94ZM203,198.07A32,32,0,0,1,160,168h0v-.11a32,32,0,0,1,60.32-14.78l1.81,4.13A32,32,0,0,1,203,198.07Z"/></symbol>
<symbol id="buildings" viewBox="0 0 256 256"><path d="M239.73,208H224V96a16,16,0,0,0-16-16H164a4,4,0,0,0-4,4V208H144V32.41a16.43,16.43,0,0,0-6.16-13,16,16,0,0,0-18.72-.69L39.12,72A16,16,0,0,0,32,85.34V208H16.27A8.18,8.18,0,0,0,8,215.47,8,8,0,0,0,16,224H240a8,8,0,0,0,8-8.53A8.18,8.18,0,0,0,239.73,208ZM76,184a8,8,0,0,1-8.53,8A8.18,8.18,0,0,1,60,183.72V168.27A8.19,8.19,0,0,1,67.47,160,8,8,0,0,1,76,168Zm0-56a8,8,0,0,1-8.53,8A8.19,8.19,0,0,1,60,127.72V112.27A8.19,8.19,0,0,1,67.47,104,8,8,0,0,1,76,112Zm40,56a8,8,0,0,1-8.53,8,8.18,8.18,0,0,1-7.47-8.26V168.27a8.19,8.19,0,0,1,7.47-8.26,8,8,0,0,1,8.53,8Zm0-56a8,8,0,0,1-8.53,8,8.19,8.19,0,0,1-7.47-8.26V112.27a8.19,8.19,0,0,1,7.47-8.26,8,8,0,0,1,8.53,8Z"/></symbol>
<symbol id="bed" viewBox="0 0 256 256"><path d="M216,72H32V48a8,8,0,0,0-16,0V208a8,8,0,0,0,16,0V176H240v32a8,8,0,0,0,16,0V112A40,40,0,0,0,216,72ZM32,88h72v72H32Z"/></symbol>
<symbol id="church" viewBox="0 0 256 256"><path d="M228.12,145.14,192,123.47V104a8,8,0,0,0-4-7L136,67.36V48h16a8,8,0,0,0,0-16H136V16a8,8,0,0,0-16,0V32H104a8,8,0,0,0,0,16h16V67.36L68,97.05a8,8,0,0,0-4,7v19.47L27.88,145.14A8,8,0,0,0,24,152v64a8,8,0,0,0,8,8h72a8,8,0,0,0,8-8V168a16,16,0,0,1,32,0v48a8,8,0,0,0,8,8h72a8,8,0,0,0,8-8V152A8,8,0,0,0,228.12,145.14ZM64,208H40V156.53l24-14.4Zm152,0H192V142.13l24,14.4Z"/></symbol>
<symbol id="soccer-ball" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm8,39.38,24.79-17.05a88.41,88.41,0,0,1,36.18,27l-8,26.94c-.2,0-.41.1-.61.17l-22.82,7.41a7.59,7.59,0,0,0-1,.4L136,88.62c0-.2,0-.41,0-.62V64C136,63.79,136,63.58,136,63.38ZM95.24,46.33,120,63.38c0,.2,0,.41,0,.62V88c0,.21,0,.42,0,.62L91.44,108.29a7.59,7.59,0,0,0-1-.4l-22.82-7.41c-.2-.07-.41-.12-.61-.17l-8-26.94A88.41,88.41,0,0,1,95.24,46.33Zm-13,129.09H53.9a87.4,87.4,0,0,1-13.79-43.07l22-16.88a5.77,5.77,0,0,0,.58.22l22.83,7.42a7.83,7.83,0,0,0,.93.22l10.79,31.42c-.15.18-.3.36-.44.55L82.7,174.71A7.8,7.8,0,0,0,82.24,175.42ZM150.69,213a88.16,88.16,0,0,1-45.38,0L95.25,184.6c.13-.16.27-.31.39-.48l14.11-19.42a7.66,7.66,0,0,0,.46-.7h35.58a7.66,7.66,0,0,0,.46.7l14.11,19.42c.12.17.26.32.39.48Zm23.07-37.61a7.8,7.8,0,0,0-.46-.71L159.19,155.3c-.14-.19-.29-.37-.44-.55l10.79-31.42a7.83,7.83,0,0,0,.93-.22l22.83-7.42a5.77,5.77,0,0,0,.58-.22l22,16.88a87.4,87.4,0,0,1-13.79,43.07Z"/></symbol>
<symbol id="tree" viewBox="0 0 256 256"><path d="M128,187.85a72.44,72.44,0,0,0,8,4.62V232a8,8,0,0,1-16,0V192.47A72.44,72.44,0,0,0,128,187.85ZM198.1,62.59a76,76,0,0,0-140.2,0A71.71,71.71,0,0,0,16,127.8C15.9,166,48,199,86.14,200A72.22,72.22,0,0,0,120,192.47V156.94L76.42,135.16a8,8,0,1,1,7.16-14.32L120,139.06V88a8,8,0,0,1,16,0v27.06l36.42-18.22a8,8,0,1,1,7.16,14.32L136,132.94v59.53A72.17,72.17,0,0,0,168,200l1.82,0C208,199,240.11,166,240,127.8A71.71,71.71,0,0,0,198.1,62.59Z"/></symbol>
<symbol id="caret-double-left" viewBox="0 0 256 256"><path d="M203.06,40.61a8,8,0,0,0-8.72,1.73L128,108.69V48a8,8,0,0,0-13.66-5.66l-80,80a8,8,0,0,0,0,11.32l80,80A8,8,0,0,0,128,208V147.31l66.34,66.35A8,8,0,0,0,208,208V48A8,8,0,0,0,203.06,40.61Z"/></symbol>
<symbol id="caret-double-right" viewBox="0 0 256 256"><path d="M221.66,122.34l-80-80A8,8,0,0,0,128,48v60.69L61.66,42.34A8,8,0,0,0,48,48V208a8,8,0,0,0,13.66,5.66L128,147.31V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,221.66,122.34Z"/></symbol>
<symbol id="pencil-simple" viewBox="0 0 256 256"><path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z"/></symbol> <symbol id="pencil-simple" viewBox="0 0 256 256"><path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z"/></symbol>
<symbol id="plus" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></symbol> <symbol id="plus" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></symbol>
<symbol id="spinner" viewBox="0 0 256 256"><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm37.25,58.75a8,8,0,0,0,5.66-2.35l22.63-22.62a8,8,0,0,0-11.32-11.32L167.6,77.09a8,8,0,0,0,5.65,13.66ZM224,120H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z"/></symbol> <symbol id="spinner" viewBox="0 0 256 256"><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm37.25,58.75a8,8,0,0,0,5.66-2.35l22.63-22.62a8,8,0,0,0-11.32-11.32L167.6,77.09a8,8,0,0,0,5.65,13.66ZM224,120H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z"/></symbol>

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before After
Before After

View file

@ -88,9 +88,9 @@
</script> </script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css"> <link rel="stylesheet" href="/css/design-system.css?v=382">
<link rel="stylesheet" href="/css/layout.css?v=237"> <link rel="stylesheet" href="/css/layout.css?v=382">
<link rel="stylesheet" href="/css/components.css?v=232"> <link rel="stylesheet" href="/css/components.css?v=382">
</head> </head>
<body> <body>
@ -124,6 +124,9 @@
<div class="sidebar-item" data-page="trainingsplaene"> <div class="sidebar-item" data-page="trainingsplaene">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg> Trainingspläne <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg> Trainingspläne
</div> </div>
<div class="sidebar-item" data-page="notes">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notizblock
</div>
<span class="sidebar-section-label">Entdecken</span> <span class="sidebar-section-label">Entdecken</span>
<div class="sidebar-item" data-page="map"> <div class="sidebar-item" data-page="map">
@ -371,6 +374,10 @@
<div class="page-body page-container"></div> <div class="page-body page-container"></div>
</section> </section>
<section class="page" id="page-notes">
<div class="page-body page-container"></div>
</section>
</main> </main>
<!-- MOBILE BOTTOM NAVIGATION --> <!-- MOBILE BOTTOM NAVIGATION -->

View file

@ -118,6 +118,7 @@ const API = (() => {
const q = new URLSearchParams(params).toString(); const q = new URLSearchParams(params).toString();
return get(`/dogs/${dogId}/diary${q ? '?' + q : ''}`); return get(`/dogs/${dogId}/diary${q ? '?' + q : ''}`);
}, },
stats(dogId) { return get(`/dogs/${dogId}/diary/stats`); },
get(dogId, entryId) { return get(`/dogs/${dogId}/diary/${entryId}`); }, get(dogId, entryId) { return get(`/dogs/${dogId}/diary/${entryId}`); },
create(dogId, data) { return post(`/dogs/${dogId}/diary`, data); }, create(dogId, data) { return post(`/dogs/${dogId}/diary`, data); },
update(dogId, id, data){ return patch(`/dogs/${dogId}/diary/${id}`, data); }, update(dogId, id, data){ return patch(`/dogs/${dogId}/diary/${id}`, data); },
@ -137,6 +138,8 @@ const API = (() => {
nearby(dogId, lat, lon) { nearby(dogId, lat, lon) {
return get(`/dogs/${dogId}/diary/nearby?lat=${lat}&lon=${lon}`); return get(`/dogs/${dogId}/diary/nearby?lat=${lat}&lon=${lon}`);
}, },
locations(dogId) { return get(`/dogs/${dogId}/diary/locations`); },
calendar(dogId) { return get(`/dogs/${dogId}/diary/calendar`); },
}; };
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -559,6 +562,30 @@ const API = (() => {
}, },
}; };
// ----------------------------------------------------------
// NOTIZEN
// ----------------------------------------------------------
const notes = {
get(parentType, parentId) {
return get(`/notes/${parentType}/${parentId}`);
},
getAll(params) {
return get('/notes?' + new URLSearchParams(params || {}).toString());
},
analyse() {
return post('/notes/ki-analyse', {});
},
create(parentType, parentId, data) {
return post(`/notes/${parentType}/${parentId}`, data);
},
update(id, data) {
return patch(`/notes/${id}`, data);
},
delete(id) {
return del(`/notes/${id}`);
},
};
// ---------------------------------------------------------- // ----------------------------------------------------------
// ERROR-KLASSE // ERROR-KLASSE
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -576,7 +603,7 @@ const API = (() => {
get, post, put, patch, del, upload, get, post, put, patch, del, upload,
auth, dogs, diary, health, tieraerzte, poison, auth, dogs, diary, health, tieraerzte, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push, places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
subscribeToPush, getLocation, subscribeToPush, getLocation,
APIError, APIError,
}; };

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '351'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '385'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => { const App = (() => {

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,10 @@ window.Page_dog_profile = (() => {
if (e.target.closest('#profile-goto-login')) { if (e.target.closest('#profile-goto-login')) {
App.navigate('settings'); App.navigate('settings');
} }
if (e.target.closest('[data-action="goto-weight"]')) {
App.navigate('health', true, { tab: 'gewicht', openForm: true });
return;
}
}); });
await _render(); await _render();
@ -119,7 +123,7 @@ window.Page_dog_profile = (() => {
</div> </div>
` : ''} ` : ''}
${dog.gewicht_kg ? ` ${dog.gewicht_kg ? `
<div class="card" style="padding:var(--space-3)"> <div class="card" style="padding:var(--space-3);cursor:pointer" data-action="goto-weight">
<div style="font-size:var(--text-xs);color:var(--c-text-secondary); <div style="font-size:var(--text-xs);color:var(--c-text-secondary);
margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg> Gewicht</div> margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg> Gewicht</div>
<div style="font-weight:500;font-size:var(--text-sm)">${dog.gewicht_kg} kg</div> <div style="font-weight:500;font-size:var(--text-sm)">${dog.gewicht_kg} kg</div>

View file

@ -186,7 +186,7 @@ window.Page_erste_hilfe = (() => {
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold)">Bedeutung</th> <th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold)">Bedeutung</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Rosa, feucht</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:#22c55e;font-weight:var(--weight-semibold)">Normal</td></tr> <tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Rosa, feucht</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-success);font-weight:var(--weight-semibold)">Normal</td></tr>
<tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Blass / weiß</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-danger)">Schock, Blutverlust, Vergiftung</td></tr> <tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Blass / weiß</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-danger)">Schock, Blutverlust, Vergiftung</td></tr>
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Blau / grau</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-danger);font-weight:var(--weight-semibold)">Sauerstoffmangel NOTFALL</td></tr> <tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Blau / grau</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-danger);font-weight:var(--weight-semibold)">Sauerstoffmangel NOTFALL</td></tr>
<tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Gelb</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-warning)">Leberprobleme</td></tr> <tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Gelb</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-warning)">Leberprobleme</td></tr>
@ -239,6 +239,7 @@ window.Page_erste_hilfe = (() => {
`; `;
_bindTabs(); _bindTabs();
_bindAccordions(); _bindAccordions();
_bindNoteButtons();
_activateTab('lebensgefahr'); _activateTab('lebensgefahr');
} }
@ -340,6 +341,10 @@ window.Page_erste_hilfe = (() => {
${massnahmenHtml} ${massnahmenHtml}
${warnHtml} ${warnHtml}
${e.extra || ''} ${e.extra || ''}
<div style="margin-top:var(--space-3);text-align:right">
<button class="btn btn-ghost btn-xs eh-note-btn" style="font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 8px"
data-kat-id="${katId}" data-titel="${e.titel.replace(/"/g,'&quot;')}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div> </div>
</div> </div>
`; `;
@ -382,6 +387,102 @@ 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : '';
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_esc(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
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 // PUBLIC
// ---------------------------------------------------------------- // ----------------------------------------------------------------

View file

@ -226,7 +226,14 @@ window.Page_events = (() => {
</a> </a>
</div>` : ''} </div>` : ''}
</div> </div>
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">${_icon('pencil-simple')}</button>` : ''} <div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten" onclick="event.stopPropagation()">${_icon('pencil-simple')}</button>` : ''}
${_state.user ? `<button class="btn-icon ev-note-btn" data-ev-note-id="${ev.id}"
data-ev-note-label="${UI.escape(ev.titel + ' ' + ev.datum)}"
data-ev-note-ort="${UI.escape(ev.ort_name || '')}"
title="Notiz" style="color:var(--c-text-muted)" onclick="event.stopPropagation()">
${_icon('note-pencil')}</button>` : ''}
</div>
</div> </div>
`; `;
} }
@ -268,7 +275,7 @@ window.Page_events = (() => {
const popup = ` const popup = `
<div style="min-width:180px"> <div style="min-width:180px">
<strong>${UI.escape(ev.titel)}</strong><br> <strong>${UI.escape(ev.titel)}</strong><br>
<span style="color:#666;font-size:12px">${datum}</span><br> <span style="color:var(--c-text-muted);font-size:12px">${datum}</span><br>
${ev.ort_name ? `<span style="font-size:12px">📍 ${UI.escape(ev.ort_name)}</span><br>` : ''} ${ev.ort_name ? `<span style="font-size:12px">📍 ${UI.escape(ev.ort_name)}</span><br>` : ''}
${ev.beschreibung ? `<span style="font-size:12px">${UI.escape(ev.beschreibung.slice(0, 80))}${ev.beschreibung.length > 80 ? '…' : ''}</span><br>` : ''} ${ev.beschreibung ? `<span style="font-size:12px">${UI.escape(ev.beschreibung.slice(0, 80))}${ev.beschreibung.length > 80 ? '…' : ''}</span><br>` : ''}
<a href="#" onclick="event.preventDefault();Page_events._openDetail(${ev.id})" <a href="#" onclick="event.preventDefault();Page_events._openDetail(${ev.id})"
@ -634,11 +641,77 @@ window.Page_events = (() => {
return; 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 // Karten-Klick → Detail
const card = e.target.closest('[data-ev-id]'); const card = e.target.closest('[data-ev-id]');
if (card) { _showDetail(parseInt(card.dataset.evId)); } 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 = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="ev-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="ev-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu diesem Event…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="ev-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="ev-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
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 }; return { init, refresh, openNew, _openDetail: _showDetail };
})(); })();

View file

@ -44,7 +44,7 @@ window.Page_friends = (() => {
<div style="width:36px;height:36px;border-radius:50%;flex-shrink:0; <div style="width:36px;height:36px;border-radius:50%;flex-shrink:0;
background:var(--c-primary-subtle); background:var(--c-primary-subtle);
display:flex;align-items:center;justify-content:center"> display:flex;align-items:center;justify-content:center">
<svg style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true"> <svg style="fill:currentColor;width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#link"></use> <use href="/icons/phosphor.svg#link"></use>
</svg> </svg>
</div> </div>

View file

@ -34,10 +34,17 @@ window.Page_health = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// LIFECYCLE // LIFECYCLE
// ---------------------------------------------------------- // ----------------------------------------------------------
async function init(container, appState) { async function init(container, appState, params) {
_container = container; _container = container;
_appState = appState; _appState = appState;
if (params?.tab) {
const valid = _getTabs().some(t => t.key === params.tab);
if (valid) _activeTab = params.tab;
}
await _render(); await _render();
if (params?.openForm) {
setTimeout(() => _showForm(null, _activeTab), 200);
}
} }
async function refresh() { async function refresh() {
@ -400,6 +407,10 @@ window.Page_health = (() => {
Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon} Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
</div>` : ''} </div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''} ${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div> </div>
</div> </div>
`; `;
@ -445,6 +456,10 @@ window.Page_health = (() => {
</div>` : ''} </div>` : ''}
${e.diagnose ? `<div class="health-card-note"><b>Diagnose:</b> ${_esc(e.diagnose)}</div>` : ''} ${e.diagnose ? `<div class="health-card-note"><b>Diagnose:</b> ${_esc(e.diagnose)}</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''} ${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div> </div>
</div> </div>
`; `;
@ -493,6 +508,10 @@ window.Page_health = (() => {
</span> </span>
</div> </div>
${e.notiz ? `<div class="health-card-note" style="padding-top:var(--space-1)">${_esc(e.notiz)}</div>` : ''} ${e.notiz ? `<div class="health-card-note" style="padding-top:var(--space-1)">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-1);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="Gewicht ${_esc(e.datum)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div> </div>
`).join(''); `).join('');
@ -726,6 +745,10 @@ window.Page_health = (() => {
${interval ? ` · Abstand zur Vorherigen: ${interval} Tage` : ''} ${interval ? ` · Abstand zur Vorherigen: ${interval} Tage` : ''}
</div> </div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''} ${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="Läufigkeit ${_esc(e.datum)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div> </div>
</div>`; </div>`;
}).join(''); }).join('');
@ -760,6 +783,10 @@ window.Page_health = (() => {
${e.bis_datum ? ` · bis ${UI.time.format(e.bis_datum + 'T00:00:00')}` : ''} ${e.bis_datum ? ` · bis ${UI.time.format(e.bis_datum + 'T00:00:00')}` : ''}
</div> </div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''} ${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div> </div>
</div> </div>
`).join('')} `).join('')}
@ -797,6 +824,10 @@ window.Page_health = (() => {
</div> </div>
${e.reaktion ? `<div class="health-card-note"><b>Reaktion:</b> ${_esc(e.reaktion)}</div>` : ''} ${e.reaktion ? `<div class="health-card-note"><b>Reaktion:</b> ${_esc(e.reaktion)}</div>` : ''}
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''} ${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div> </div>
</div> </div>
`).join(''); `).join('');
@ -837,6 +868,10 @@ window.Page_health = (() => {
${count > 1 ? ` · ${count} Dateien` : ''} ${count > 1 ? ` · ${count} Dateien` : ''}
</div> </div>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''} ${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${_esc(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
${count ${count
? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);align-items:center;flex-wrap:wrap"> ? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);align-items:center;flex-wrap:wrap">
${mediaList.slice(0, 3).map(m => m.media_type === 'pdf' ${mediaList.slice(0, 3).map(m => m.media_type === 'pdf'
@ -874,6 +909,14 @@ window.Page_health = (() => {
const entry = (_data[_activeTab] || []).find(e => e.id === id); const entry = (_data[_activeTab] || []).find(e => e.id === id);
if (entry) card.addEventListener('click', () => _openDetail(entry)); 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 // Praxis öffnen
content.querySelectorAll('[data-action="open-praxis"]').forEach(el => { content.querySelectorAll('[data-action="open-praxis"]').forEach(el => {
el.addEventListener('click', () => { el.addEventListener('click', () => {
@ -1166,6 +1209,9 @@ window.Page_health = (() => {
if (!_data[t]) _data[t] = []; if (!_data[t]) _data[t] = [];
_data[t].unshift(saved); _data[t].unshift(saved);
UI.toast.success('Eintrag erstellt.'); UI.toast.success('Eintrag erstellt.');
if (t === 'gewicht' && saved.wert) {
_appState.activeDog.gewicht_kg = saved.wert;
}
} }
// Multi-File-Upload // Multi-File-Upload
@ -1830,6 +1876,89 @@ window.Page_health = (() => {
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;');
} }
// ----------------------------------------------------------
// 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 = `
<div style="background:var(--c-surface);border-radius:var(--radius-xl) var(--radius-xl) 0 0;
width:100%;max-width:640px;max-height:90vh;display:flex;flex-direction:column;
padding-bottom:env(safe-area-inset-bottom,0px)">
<div style="padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--c-border);
display:flex;align-items:center;justify-content:space-between;flex-shrink:0">
<div>
<div style="font-weight:var(--weight-semibold);font-size:var(--text-base)"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_esc(parentLabel)}</div>
</div>
<button id="by-note-close" style="background:none;border:none;cursor:pointer;font-size:1.4rem;color:var(--c-text-muted);padding:4px 8px" aria-label="Schließen">×</button>
</div>
<div style="padding:var(--space-4) var(--space-5);flex:1;overflow-y:auto">
<form id="by-note-form">
<textarea id="by-note-text" class="form-control" rows="5"
placeholder="Notiz eingeben…"
style="width:100%;resize:vertical"></textarea>
</form>
</div>
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
display:flex;gap:var(--space-2);flex-shrink:0">
<button type="button" id="by-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button type="submit" form="by-note-form" id="by-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
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 }; return { init, refresh, openNew, onDogChange };
})(); })();

View file

@ -0,0 +1,693 @@
/* ============================================================
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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]) => `
<div class="notes-group">
<div class="notes-group-label">${_esc(label)}</div>
${items.map(_noteCard).join('')}
</div>
`).join('');
_container.innerHTML = `
<div class="notes-page">
<!-- Header -->
<div class="notes-header">
<h2 class="notes-title">Notizblock</h2>
<span class="notes-count">${_notes.length} Notiz${_notes.length !== 1 ? 'en' : ''}</span>
</div>
<!-- KI-Panel -->
${kiEnabled ? _kiPanelHtml() : ''}
<!-- Filter-Chips -->
<div class="notes-filter-chips">
${RUBRIKEN.map(r => `
<button class="notes-chip ${_filterType === r.type ? 'notes-chip--active' : ''}"
data-type="${_esc(r.type)}"
style="${_filterType === r.type ? `--chip-color:${r.color}` : ''}">
${_esc(r.label)}
</button>
`).join('')}
</div>
<!-- Suche + Sortierung -->
<div class="notes-toolbar">
<div class="notes-search-wrap">
<i class="ph ph-magnifying-glass notes-search-icon"></i>
<input id="notes-search" type="search" class="notes-search-input"
placeholder="Suche…" value="${_esc(_searchQ)}">
</div>
<div class="notes-sort-btns">
<button class="notes-sort-btn ${_sortMode === 'newest' ? 'notes-sort-btn--active' : ''}"
data-sort="newest">Neueste</button>
<button class="notes-sort-btn ${_sortMode === 'type' ? 'notes-sort-btn--active' : ''}"
data-sort="type">Rubrik</button>
<button class="notes-sort-btn ${_sortMode === 'location' ? 'notes-sort-btn--active' : ''}"
data-sort="location">Ort</button>
</div>
</div>
<!-- Liste -->
<div class="notes-list">
${sorted.length === 0
? UI.emptyState({ icon: 'note', title: 'Keine Notizen', text: 'Füge Notizen zu Trainingseinheiten oder anderen Einträgen hinzu.' })
: groupHtml
}
</div>
</div>
<style>
.notes-page { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); }
.notes-header { display: flex; align-items: center; justify-content: space-between; }
.notes-title { font-size: var(--text-lg); font-weight: var(--weight-bold); color: var(--c-text); margin: 0; }
.notes-count { font-size: var(--text-xs); color: var(--c-text-muted); }
/* KI-Panel */
.notes-ki-panel { background: var(--c-surface-2); border: 1.5px solid var(--c-border); border-radius: var(--radius-lg); overflow: hidden; }
.notes-ki-header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-3) var(--space-4); cursor: pointer; gap: var(--space-2); }
.notes-ki-header-left { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--c-text); }
.notes-ki-chevron { transition: transform .2s; color: var(--c-text-muted); }
.notes-ki-chevron--open { transform: rotate(180deg); }
.notes-ki-body { padding: var(--space-3) var(--space-4) var(--space-4); border-top: 1px solid var(--c-border); }
.notes-ki-btn { padding: var(--space-2) var(--space-4); border-radius: var(--radius-md); border: none; background: var(--c-primary); color: #fff; font-size: var(--text-sm); font-weight: var(--weight-semibold); cursor: pointer; }
.notes-ki-btn:disabled { opacity: .6; cursor: default; }
.notes-ki-suggestions { margin-top: var(--space-3); font-size: var(--text-sm); color: var(--c-text); line-height: 1.6; }
.notes-ki-suggestions ul { margin: var(--space-2) 0 0; padding-left: var(--space-4); }
.notes-ki-suggestions li { margin-bottom: var(--space-1); }
.notes-ki-error { margin-top: var(--space-2); font-size: var(--text-sm); color: var(--c-danger); }
/* Filter-Chips */
.notes-filter-chips { display: flex; gap: var(--space-2); overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
.notes-filter-chips::-webkit-scrollbar { display: none; }
.notes-chip { flex-shrink: 0; font-size: var(--text-xs); font-weight: var(--weight-semibold); padding: 4px var(--space-3); border-radius: 999px; border: 1.5px solid var(--c-border); background: var(--c-surface-2); color: var(--c-text-secondary); cursor: pointer; white-space: nowrap; transition: background .15s, color .15s, border-color .15s; }
.notes-chip--active { background: var(--chip-color, var(--c-primary)); color: #fff; border-color: var(--chip-color, var(--c-primary)); }
/* Toolbar */
.notes-toolbar { display: flex; gap: var(--space-2); align-items: center; }
.notes-search-wrap { position: relative; flex: 1; }
.notes-search-icon { position: absolute; left: var(--space-3); top: 50%; transform: translateY(-50%); color: var(--c-text-muted); font-size: 1rem; pointer-events: none; }
.notes-search-input { width: 100%; padding: var(--space-2) var(--space-3) var(--space-2) calc(var(--space-3) + 1.3rem); border: 1.5px solid var(--c-border); border-radius: var(--radius-md); font-size: var(--text-sm); background: var(--c-surface); color: var(--c-text); outline: none; box-sizing: border-box; }
.notes-search-input:focus { border-color: var(--c-primary); }
.notes-sort-btns { display: flex; border: 1.5px solid var(--c-border); border-radius: var(--radius-md); overflow: hidden; flex-shrink: 0; }
.notes-sort-btn { padding: var(--space-2) var(--space-3); font-size: var(--text-xs); font-weight: var(--weight-semibold); border: none; background: var(--c-surface-2); color: var(--c-text-secondary); cursor: pointer; transition: background .15s, color .15s; border-right: 1px solid var(--c-border); }
.notes-sort-btn:last-child { border-right: none; }
.notes-sort-btn--active { background: var(--c-primary); color: #fff; }
/* Gruppen */
.notes-group { display: flex; flex-direction: column; gap: var(--space-2); }
.notes-group-label { font-size: var(--text-xs); font-weight: var(--weight-semibold); color: var(--c-text-muted); text-transform: uppercase; letter-spacing: .05em; padding: var(--space-1) 0; }
/* Karten */
.notes-card { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-lg); padding: var(--space-3) var(--space-4); display: flex; flex-direction: column; gap: var(--space-2); }
.notes-card-top { display: flex; align-items: flex-start; gap: var(--space-2); }
.notes-rubrik-chip { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-xs); font-weight: var(--weight-semibold); padding: 2px var(--space-2); border-radius: 999px; flex-shrink: 0; }
.notes-parent-label { font-size: var(--text-xs); color: var(--c-text-secondary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; align-self: center; }
.notes-card-meta { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-xs); color: var(--c-text-muted); }
.notes-card-actions { display: flex; gap: var(--space-2); margin-left: auto; flex-shrink: 0; }
.notes-card-text { font-size: var(--text-sm); color: var(--c-text); line-height: 1.55; white-space: pre-wrap; margin: 0; }
.notes-micro-badges { display: flex; flex-wrap: wrap; gap: var(--space-1); }
.notes-micro-badge { font-size: var(--text-xs); padding: 2px 6px; border-radius: var(--radius-sm); background: var(--c-surface-2); color: var(--c-text-secondary); }
.notes-action-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--c-border); background: var(--c-surface-2); color: var(--c-text-muted); cursor: pointer; font-size: 1rem; transition: background .15s, color .15s; }
.notes-action-btn:hover { background: var(--c-surface); color: var(--c-text); }
.notes-action-btn--danger:hover { background: #fef2f2; color: var(--c-danger); border-color: var(--c-danger); }
.notes-list { display: flex; flex-direction: column; gap: var(--space-4); }
</style>
`;
_bindEvents();
}
// ----------------------------------------------------------
// KI-Panel HTML
// ----------------------------------------------------------
function _kiPanelHtml() {
return `
<div class="notes-ki-panel" id="notes-ki-panel">
<div class="notes-ki-header" id="notes-ki-toggle">
<div class="notes-ki-header-left">
<i class="ph ph-robot"></i>
Muster-Analyse
</div>
<i class="ph ph-caret-down notes-ki-chevron ${_kiOpen ? 'notes-ki-chevron--open' : ''}" id="notes-ki-chevron"></i>
</div>
${_kiOpen ? `
<div class="notes-ki-body" id="notes-ki-body">
<button class="notes-ki-btn" id="notes-ki-analyse-btn" ${_kiLoading ? 'disabled' : ''}>
${_kiLoading ? '<i class="ph ph-spinner-gap"></i> Analysiere…' : 'Analysieren'}
</button>
${_kiError ? `<div class="notes-ki-error"><i class="ph ph-warning-circle"></i> ${_esc(_kiError)}</div>` : ''}
${_kiSuggestions ? `
<div class="notes-ki-suggestions">
<ul>
${_kiSuggestions.map(s => `<li>${_esc(s)}</li>`).join('')}
</ul>
</div>
` : ''}
</div>
` : ''}
</div>
`;
}
// ----------------------------------------------------------
// 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 `
<div class="notes-card" data-id="${note.id}">
<!-- Top-Zeile: Rubrik-Chip + parent_label + Zeit + Buttons -->
<div class="notes-card-top">
<span class="notes-rubrik-chip"
style="background:${rb.color}22;color:${rb.color}">
<i class="ph ph-${rb.icon}"></i>
${_esc(rb.label)}
</span>
${note.parent_label
? `<span class="notes-parent-label" title="${_esc(note.parent_label)}">${_esc(note.parent_label)}</span>`
: ''
}
<div class="notes-card-actions">
<button class="notes-action-btn notes-edit-btn" data-id="${note.id}" title="Bearbeiten">
<i class="ph ph-pencil"></i>
</button>
<button class="notes-action-btn notes-action-btn--danger notes-delete-btn" data-id="${note.id}" title="Löschen">
<i class="ph ph-trash"></i>
</button>
</div>
</div>
<!-- Notiztext -->
<p class="notes-card-text">${_esc(_truncate(note.text))}</p>
<!-- Micro-Badges -->
${microBadges.length ? `
<div class="notes-micro-badges">
${microBadges.map(b => `<span class="notes-micro-badge">${_esc(b)}</span>`).join('')}
</div>
` : ''}
<!-- Meta: Zeit + Ort -->
<div class="notes-card-meta">
<i class="ph ph-clock"></i>
${_esc(_formatTime(note.updated_at || note.created_at))}
${hasLocation ? `<i class="ph ph-map-pin"></i> ${_esc(note.location_name)}` : ''}
</div>
</div>
`;
}
// ----------------------------------------------------------
// 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 = `
<div style="background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
width:100%;max-width:480px;max-height:85vh;overflow-y:auto;
padding:var(--space-5) var(--space-4) var(--space-6);box-shadow:0 -4px 24px rgba(0,0,0,0.15)">
<!-- Griff -->
<div style="width:40px;height:4px;background:var(--c-border);border-radius:2px;margin:0 auto var(--space-4)"></div>
<!-- Kopfzeile -->
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-4)">
<span style="display:inline-flex;align-items:center;gap:4px;font-size:var(--text-xs);
font-weight:var(--weight-semibold);padding:2px var(--space-2);border-radius:999px;
background:${rb.color}22;color:${rb.color}">
<i class="ph ph-${rb.icon}"></i> ${_esc(rb.label)}
</span>
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);margin:0">
Notiz bearbeiten
</h3>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
<!-- Freitext -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Text</label>
<textarea id="notes-edit-text" rows="5"
style="width:100%;padding:var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);
font-family:var(--font-sans);background:var(--c-surface);
color:var(--c-text);resize:vertical;outline:none;line-height:1.5;
box-sizing:border-box">${_esc(note.text)}</textarea>
</div>
${note.parent_type === 'training_session' ? `
<!-- Bewertung -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Bewertung</label>
<div style="display:flex;gap:var(--space-2)">
${[1,2,3,4,5].map(n => `
<button type="button" class="notes-pfote" data-val="${n}"
style="font-size:1.3rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
padding:3px 9px;cursor:pointer;
background:${(meta.erfolgsquote||0)===n?'var(--c-primary-subtle)':'var(--c-surface-2)'};
border-color:${(meta.erfolgsquote||0)===n?'var(--c-primary)':'var(--c-border)'}">🐾</button>
`).join('')}
</div>
</div>
<!-- Umgebung -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Umgebung</label>
<div style="display:flex;gap:var(--space-2)">
${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji,val]) => `
<button type="button" class="notes-umgebung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
padding:3px 10px;cursor:pointer;
background:${meta.umgebung===val?'var(--c-primary-subtle)':'var(--c-surface-2)'};
border-color:${meta.umgebung===val?'var(--c-primary)':'var(--c-border)'}">${emoji}</button>
`).join('')}
</div>
</div>
<!-- Stimmung -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Stimmung des Hundes</label>
<div style="display:flex;gap:var(--space-2)">
${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji,val]) => `
<button type="button" class="notes-stimmung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);border-radius:var(--radius-md);
padding:3px 10px;cursor:pointer;
background:${meta.hund_stimmung===val?'var(--c-primary-subtle)':'var(--c-surface-2)'};
border-color:${meta.hund_stimmung===val?'var(--c-primary)':'var(--c-border)'}">${emoji}</button>
`).join('')}
</div>
</div>
` : ''}
</div>
<!-- Buttons -->
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-5)">
<button id="notes-edit-delete" type="button"
style="padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);
border:1.5px solid var(--c-danger);background:none;
color:var(--c-danger);font-size:var(--text-sm);cursor:pointer">
Löschen
</button>
<button id="notes-edit-cancel" type="button"
style="flex:1;padding:var(--space-3);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:none;
color:var(--c-text-secondary);font-size:var(--text-sm);cursor:pointer">
Abbrechen
</button>
<button id="notes-edit-save" type="button"
style="flex:2;padding:var(--space-3);border-radius:var(--radius-md);
border:none;background:var(--c-primary);
color:#fff;font-size:var(--text-sm);font-weight:var(--weight-semibold);cursor:pointer">
Speichern
</button>
</div>
</div>
`;
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 };
})();

View file

@ -126,7 +126,7 @@ window.Page_onboarding = (() => {
<div style="width:36px;height:36px;border-radius:var(--radius-md); <div style="width:36px;height:36px;border-radius:var(--radius-md);
background:var(--c-primary-subtle);flex-shrink:0; background:var(--c-primary-subtle);flex-shrink:0;
display:flex;align-items:center;justify-content:center"> display:flex;align-items:center;justify-content:center">
<svg style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true"> <svg style="fill:currentColor;width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use> <use href="/icons/phosphor.svg#${icon}"></use>
</svg> </svg>
</div> </div>
@ -167,7 +167,7 @@ window.Page_onboarding = (() => {
<div style="width:64px;height:64px;border-radius:50%; <div style="width:64px;height:64px;border-radius:50%;
background:var(--c-primary-subtle);margin:0 auto var(--space-4); background:var(--c-primary-subtle);margin:0 auto var(--space-4);
display:flex;align-items:center;justify-content:center"> display:flex;align-items:center;justify-content:center">
<svg style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true"> <svg style="fill:currentColor;width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#dog"></use> <use href="/icons/phosphor.svg#dog"></use>
</svg> </svg>
</div> </div>
@ -262,7 +262,7 @@ window.Page_onboarding = (() => {
<div style="width:80px;height:80px;border-radius:50%; <div style="width:80px;height:80px;border-radius:50%;
background:var(--c-success-subtle,#dcfce7);margin:0 auto var(--space-4); background:var(--c-success-subtle,#dcfce7);margin:0 auto var(--space-4);
display:flex;align-items:center;justify-content:center"> display:flex;align-items:center;justify-content:center">
<svg style="width:40px;height:40px;color:var(--c-success)" aria-hidden="true"> <svg style="fill:currentColor;width:40px;height:40px;color:var(--c-success)" aria-hidden="true">
<use href="/icons/phosphor.svg#check-circle"></use> <use href="/icons/phosphor.svg#check-circle"></use>
</svg> </svg>
</div> </div>

View file

@ -1805,6 +1805,7 @@ window.Page_routes = (() => {
${_actionBtn('rd-gpx', 'download-simple', 'GPX')} ${_actionBtn('rd-gpx', 'download-simple', 'GPX')}
${_actionBtn('rd-share', 'arrow-square-out', 'Teilen')} ${_actionBtn('rd-share', 'arrow-square-out', 'Teilen')}
${_actionBtn('rd-navi', 'map-pin', 'Navi')} ${_actionBtn('rd-navi', 'map-pin', 'Navi')}
${_appState.user ? _actionBtn('rd-note', 'note-pencil', 'Notiz') : ''}
</div> </div>
${ownerRow} ${ownerRow}
<button type="button" class="btn btn-primary w-full" id="rd-close">Schließen</button> <button type="button" class="btn btn-primary w-full" id="rd-close">Schließen</button>
@ -1920,6 +1921,12 @@ window.Page_routes = (() => {
} catch (err) { UI.toast.error(err.message); } } 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 // Mini-Map
let _detailMap = null; let _detailMap = null;
setTimeout(() => { setTimeout(() => {
@ -2504,6 +2511,59 @@ 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 = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="rk-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="rk-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu dieser Route…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="rk-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="rk-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
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 }; return { init, refresh, onDogChange };
})(); })();

View file

@ -266,6 +266,30 @@ window.Page_settings = (() => {
</select> </select>
</div> </div>
<!-- KI-Notiz-Assistent -->
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4)">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#brain"></use></svg>
<div style="flex:1">
<div style="font-weight:500">KI-Notiz-Assistent</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Erkennt Muster in deinen Notizen und macht Vorschläge
</div>
</div>
<label style="position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0">
<input type="checkbox" id="toggle-notes-ki"
style="opacity:0;width:0;height:0;position:absolute"
${u.notes_ki_enabled ? 'checked' : ''}>
<span style="position:absolute;cursor:pointer;inset:0;border-radius:12px;
background:var(--c-border);transition:.2s"
id="toggle-notes-ki-track"></span>
<span id="toggle-notes-ki-thumb"
style="position:absolute;top:2px;left:${u.notes_ki_enabled ? '22px' : '2px'};
width:20px;height:20px;border-radius:50%;
background:#fff;transition:.2s;
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
</label>
</div>
</div> </div>
</div> </div>
@ -635,6 +659,25 @@ window.Page_settings = (() => {
: 'Pocket-Modus deaktiviert.'); : '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(); _loadReferral();
} }

View file

@ -136,6 +136,12 @@ window.Page_sitting = (() => {
<div class="sitting-card-side"> <div class="sitting-card-side">
<div class="sitting-price">${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €/Tag' : 'Preis anfragen'}</div> <div class="sitting-price">${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €/Tag' : 'Preis anfragen'}</div>
<div class="sitting-dogs">max. ${s.max_hunde} Hund${s.max_hunde !== 1 ? 'e' : ''}</div> <div class="sitting-dogs">max. ${s.max_hunde} Hund${s.max_hunde !== 1 ? 'e' : ''}</div>
${_state.user ? `<button class="btn-icon sit-note-btn"
data-sit-note-id="${s.id}"
data-sit-note-label="${UI.escHtml(s.sitter_name + ' ' + (s.datum || ''))}"
title="Notiz" style="color:var(--c-text-muted);margin-top:var(--space-1)"
onclick="event.stopPropagation()">
${UI.icon('note-pencil')}</button>` : ''}
</div> </div>
</div> </div>
`; `;
@ -704,6 +710,19 @@ window.Page_sitting = (() => {
return; 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 // Sitter-Karte
const sitterCard = e.target.closest('[data-sit-id]'); const sitterCard = e.target.closest('[data-sit-id]');
if (sitterCard && !e.target.closest('button')) { if (sitterCard && !e.target.closest('button')) {
@ -741,6 +760,59 @@ window.Page_sitting = (() => {
} catch (e) { UI.toast(e.message, 'error'); } } 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 = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="sit-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="sit-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu diesem Sitter…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="sit-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="sit-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
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 }; return { init, refresh };
})(); })();

View file

@ -895,6 +895,7 @@ window.Page_uebungen = (() => {
_bindAccordions(); _bindAccordions();
_bindStatusButtons(); _bindStatusButtons();
_bindLogButtons(); _bindLogButtons();
_bindNotizButtons();
if (_activeTab === 'ki-trainer') _loadKiTrainerFeedback(); if (_activeTab === 'ki-trainer') _loadKiTrainerFeedback();
} }
@ -965,6 +966,19 @@ window.Page_uebungen = (() => {
Einheit Einheit
</button> </button>
${_sessionStatsChip(_activeTab, u.name)} ${_sessionStatsChip(_activeTab, u.name)}
<button class="ueb-notiz-btn"
data-tab="${_esc(_activeTab)}"
data-name="${_esc(u.name)}"
title="Notiz hinzufügen"
style="background:none;border:1px solid var(--c-border);cursor:pointer;
padding:3px 7px;border-radius:var(--radius-sm);
display:flex;align-items:center;gap:3px;
font-size:var(--text-xs);color:var(--c-text-secondary)">
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#note-pencil"></use>
</svg>
Notiz
</button>
<button class="ueb-status-btn" <button class="ueb-status-btn"
data-tab="${_esc(_activeTab)}" data-tab="${_esc(_activeTab)}"
data-name="${_esc(u.name)}" data-name="${_esc(u.name)}"
@ -1006,7 +1020,7 @@ window.Page_uebungen = (() => {
background:#78350f22;border:1px solid #d9770644;border-radius:var(--radius-sm); background:#78350f22;border:1px solid #d9770644;border-radius:var(--radius-sm);
font-size:var(--text-xs);color:var(--c-text);line-height:1.4; font-size:var(--text-xs);color:var(--c-text);line-height:1.4;
display:flex;align-items:flex-start;gap:var(--space-2)"> display:flex;align-items:flex-start;gap:var(--space-2)">
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0;margin-top:1px;color:#f59e0b" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> <svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0;margin-top:1px;color:var(--c-warning)" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
<span>${_esc(u.hinweis)}</span> <span>${_esc(u.hinweis)}</span>
</div> </div>
` : ''} ` : ''}
@ -1039,7 +1053,7 @@ window.Page_uebungen = (() => {
${u.fehler.length ? ` ${u.fehler.length ? `
<p style="font-size:var(--text-xs);font-weight:var(--weight-semibold); <p style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
color:var(--c-text-secondary);margin-bottom:var(--space-2);text-transform:uppercase;letter-spacing:0.05em"> color:var(--c-text-secondary);margin-bottom:var(--space-2);text-transform:uppercase;letter-spacing:0.05em">
<svg class="ph-icon" style="width:12px;height:12px;color:#f59e0b" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> <svg class="ph-icon" style="width:12px;height:12px;color:var(--c-warning)" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
Häufige Fehler Häufige Fehler
</p> </p>
<ul style="margin:0 0 var(--space-4);padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-2)"> <ul style="margin:0 0 var(--space-4);padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-2)">
@ -1100,6 +1114,252 @@ window.Page_uebungen = (() => {
}); });
} }
function _bindNotizButtons() {
_container.querySelectorAll('.ueb-notiz-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const exerciseId = `${btn.dataset.tab}_${btn.dataset.name.replace(/[\s/]+/g, '_')}`;
_openNotizModal(exerciseId, btn.dataset.name, btn);
});
});
}
async function _openNotizModal(exerciseId, exerciseName, triggerBtn) {
const modalId = 'ueb-notiz-modal';
document.getElementById(modalId)?.remove();
// Lade bestehende Notiz
let existingNote = null;
if (_appState?.user) {
try {
const notes = await API.notes.get('training_session', exerciseId.length > 0 ? exerciseId : 0);
if (notes && notes.length > 0) existingNote = notes[0];
} catch (_) {}
}
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);
`;
const noteText = existingNote?.text || '';
const meta = existingNote?.meta_json || {};
const currentErfolgsquote = meta.erfolgsquote || null;
const currentUmgebung = meta.umgebung || null;
const currentStimmung = meta.hund_stimmung || null;
overlay.innerHTML = `
<div style="background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
width:100%;max-width:480px;max-height:85vh;overflow-y:auto;
padding:var(--space-5) var(--space-4) var(--space-6);box-shadow:0 -4px 24px rgba(0,0,0,0.15)">
<div style="width:40px;height:4px;background:var(--c-border);border-radius:2px;margin:0 auto var(--space-4)"></div>
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);
margin:0 0 var(--space-4);text-align:center">
Notiz: ${_esc(exerciseName)}
</h3>
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
<!-- Freitext -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Notiz</label>
<textarea id="ueb-notiz-text" rows="3"
placeholder="Was ist dir aufgefallen? Tipps für nächstes Mal…"
style="width:100%;padding:var(--space-3);border:1.5px solid var(--c-border);
border-radius:var(--radius-md);font-size:var(--text-sm);
font-family:var(--font-sans);background:var(--c-surface);
color:var(--c-text);resize:vertical;outline:none;
line-height:1.5">${_esc(noteText)}</textarea>
</div>
<!-- Erfolgsquote -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Bewertung (optional)</label>
<div style="display:flex;gap:var(--space-2)">
${[1,2,3,4,5].map(n => `
<button type="button" class="ueb-notiz-pfote" data-val="${n}"
style="font-size:1.4rem;border:1.5px solid var(--c-border);
border-radius:var(--radius-md);padding:4px 10px;cursor:pointer;
background:${currentErfolgsquote === n ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'};
border-color:${currentErfolgsquote === n ? 'var(--c-primary)' : 'var(--c-border)'};
transition:all 0.15s">🐾</button>
`).join('')}
</div>
</div>
<!-- Umgebung -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Umgebung (optional)</label>
<div style="display:flex;gap:var(--space-2)">
${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji, val]) => `
<button type="button" class="ueb-notiz-umgebung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);
border-radius:var(--radius-md);padding:4px 12px;cursor:pointer;
background:${currentUmgebung === val ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'};
border-color:${currentUmgebung === val ? 'var(--c-primary)' : 'var(--c-border)'};
transition:all 0.15s">${emoji}</button>
`).join('')}
</div>
</div>
<!-- Hund-Stimmung -->
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-2)">Stimmung des Hundes (optional)</label>
<div style="display:flex;gap:var(--space-2)">
${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji, val]) => `
<button type="button" class="ueb-notiz-stimmung" data-val="${val}"
style="font-size:1.2rem;border:1.5px solid var(--c-border);
border-radius:var(--radius-md);padding:4px 12px;cursor:pointer;
background:${currentStimmung === val ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'};
border-color:${currentStimmung === val ? 'var(--c-primary)' : 'var(--c-border)'};
transition:all 0.15s">${emoji}</button>
`).join('')}
</div>
</div>
</div>
<!-- Buttons -->
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-5)">
${existingNote ? `
<button id="ueb-notiz-delete" type="button"
style="padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);
border:1.5px solid var(--c-danger);background:none;
color:var(--c-danger);font-size:var(--text-sm);cursor:pointer">
Löschen
</button>
` : ''}
<button id="ueb-notiz-cancel" type="button"
style="flex:1;padding:var(--space-3);border-radius:var(--radius-md);
border:1.5px solid var(--c-border);background:none;
color:var(--c-text-secondary);font-size:var(--text-sm);cursor:pointer">
Abbrechen
</button>
<button id="ueb-notiz-save" type="button"
style="flex:2;padding:var(--space-3);border-radius:var(--radius-md);
border:none;background:var(--c-primary);
color:#fff;font-size:var(--text-sm);font-weight:var(--weight-semibold);cursor:pointer">
${existingNote ? 'Aktualisieren' : 'Speichern'}
</button>
</div>
</div>
`;
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) { function _openLogModal(tab, exerciseName, initialReps) {
// Build the modal HTML // Build the modal HTML
const modalId = 'ueb-log-modal'; const modalId = 'ueb-log-modal';

View file

@ -192,6 +192,18 @@ window.Page_walks = (() => {
el.querySelectorAll('.walks-card').forEach(card => { el.querySelectorAll('.walks-card').forEach(card => {
card.addEventListener('click', () => _openDetail(parseInt(card.dataset.id))); 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) { function _walkCardHTML(w) {
@ -217,7 +229,16 @@ window.Page_walks = (() => {
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''} ${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
</div> </div>
</div> </div>
<div class="walks-card-arrow"></div> <div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
<div class="walks-card-arrow"></div>
${_appState.user ? `<button class="btn-icon wk-note-btn"
data-wk-note-id="${w.id}"
data-wk-note-label="${UI.escape(w.titel + ' ' + w.datum)}"
data-wk-note-ort="${UI.escape(w.ort_name || '')}"
title="Notiz" style="color:var(--c-text-muted);font-size:var(--text-xs)"
onclick="event.stopPropagation()">
${UI.icon('note-pencil')}</button>` : ''}
</div>
</div>`; </div>`;
} }
@ -964,6 +985,59 @@ 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 = `
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz ${UI.escape(parentLabel)}</span>
<button id="wk-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
<textarea id="wk-note-text" rows="5"
style="width:100%;box-sizing:border-box;padding:var(--space-3);
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
font-size:var(--text-sm);font-family:inherit;
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
placeholder="Deine Notiz zu diesem Gassi-Treffen…">${UI.escape(existingNote?.text || '')}</textarea>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button id="wk-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
<button id="wk-note-save" class="btn btn-primary flex-1">Speichern</button>
</div>
</div>
`;
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 }; return { init, refresh, onDogChange, openNew, openDetail: _openDetail };
})(); })();

View file

@ -53,7 +53,7 @@ window.Page_welcome = (() => {
style="width:36px;height:36px;border-radius:var(--radius-md); style="width:36px;height:36px;border-radius:var(--radius-md);
background:var(--c-primary);flex-shrink:0; background:var(--c-primary);flex-shrink:0;
display:flex;align-items:center;justify-content:center"> display:flex;align-items:center;justify-content:center">
<svg style="width:20px;height:20px;color:#fff" aria-hidden="true"> <svg style="fill:currentColor;width:20px;height:20px;color:#fff" aria-hidden="true">
<use href="/icons/phosphor.svg#list"></use> <use href="/icons/phosphor.svg#list"></use>
</svg> </svg>
</div> </div>
@ -237,7 +237,7 @@ window.Page_welcome = (() => {
<div style="width:34px;height:34px;border-radius:var(--radius-md); <div style="width:34px;height:34px;border-radius:var(--radius-md);
background:var(--c-primary-subtle);flex-shrink:0; background:var(--c-primary-subtle);flex-shrink:0;
display:flex;align-items:center;justify-content:center"> display:flex;align-items:center;justify-content:center">
<svg style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true"> <svg style="fill:currentColor;width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use> <use href="/icons/phosphor.svg#${icon}"></use>
</svg> </svg>
</div> </div>

View file

@ -3,15 +3,15 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v370'; const CACHE_VERSION = 'by-v405';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
// index.html wird NICHT pre-gecacht (immer Network-First) // index.html wird NICHT pre-gecacht (immer Network-First)
const STATIC_ASSETS = [ const STATIC_ASSETS = [
'/css/design-system.css', '/css/design-system.css?v=382',
'/css/layout.css', '/css/layout.css?v=382',
'/css/components.css', '/css/components.css?v=382',
'/icons/phosphor.svg', '/icons/phosphor.svg',
'/js/api.js', '/js/api.js',
'/js/ui.js', '/js/ui.js',
@ -82,8 +82,8 @@ self.addEventListener('fetch', event => {
return; return;
} }
// Seiten-Module (/js/pages/…): immer Network-First (versioniert über ?v=, kein alter Cache-Treffer) // CSS + Seiten-Module: immer Network-First — damit iOS nie veraltete CSS cached
if (url.pathname.startsWith('/js/pages/')) { if (url.pathname.startsWith('/css/') || url.pathname.startsWith('/js/pages/')) {
event.respondWith( event.respondWith(
fetch(event.request) fetch(event.request)
.then(response => { .then(response => {

View file

@ -0,0 +1,410 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Banyaro für Hundeschulen</title>
<style>
@page {
size: A4;
margin: 0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #1a2332;
background: white;
line-height: 1.5;
}
.page {
width: 210mm;
min-height: 297mm;
padding: 18mm 16mm 14mm 16mm;
background: white;
position: relative;
}
/* HEADER — Hundeschul-Variante: warmer Akzent (orange-rot) statt klinisches Grün */
.header {
border-bottom: 3px solid #c25b2e;
padding-bottom: 10mm;
margin-bottom: 8mm;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.brand {
display: flex;
flex-direction: column;
}
.brand-name {
font-size: 32pt;
font-weight: 700;
color: #c25b2e;
letter-spacing: -0.5px;
line-height: 1;
}
.brand-tagline {
font-size: 11pt;
color: #5a6470;
margin-top: 4px;
font-weight: 400;
}
.header-right {
text-align: right;
}
.target-badge {
display: inline-block;
background: #c25b2e;
color: white;
padding: 5px 14px;
font-size: 10pt;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.url {
font-size: 11pt;
color: #c25b2e;
font-weight: 600;
margin-top: 6px;
}
/* HEADLINE */
.headline {
font-size: 18pt;
font-weight: 700;
color: #1a2332;
margin-bottom: 4mm;
line-height: 1.25;
}
.intro {
font-size: 10.5pt;
color: #3a4451;
margin-bottom: 7mm;
line-height: 1.55;
}
/* MAIN GRID */
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7mm;
margin-bottom: 7mm;
}
.section {
background: #faf3ee;
border-left: 3px solid #c25b2e;
padding: 5mm;
}
.section-title {
font-size: 11pt;
font-weight: 700;
color: #c25b2e;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 3mm;
}
.section-list {
list-style: none;
font-size: 9.5pt;
color: #1a2332;
}
.section-list li {
padding: 2px 0 2px 14px;
position: relative;
line-height: 1.5;
}
.section-list li::before {
content: "▸";
position: absolute;
left: 0;
color: #c25b2e;
font-weight: 700;
}
/* FEATURES */
.features {
margin-bottom: 7mm;
}
.features-title {
font-size: 12pt;
font-weight: 700;
color: #1a2332;
margin-bottom: 4mm;
padding-bottom: 2mm;
border-bottom: 1px solid #e5d7cb;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4mm;
}
.feature {
font-size: 9.5pt;
}
.feature-name {
font-weight: 700;
color: #c25b2e;
margin-bottom: 2px;
}
.feature-desc {
color: #3a4451;
line-height: 1.4;
}
/* PARTNERSCHAFT */
.partner {
background: linear-gradient(135deg, #c25b2e 0%, #d97a4f 100%);
color: white;
padding: 6mm;
margin-bottom: 6mm;
display: grid;
grid-template-columns: 1fr 38mm;
gap: 6mm;
align-items: center;
}
.partner-content {
color: white;
}
.partner-title {
font-size: 13pt;
font-weight: 700;
margin-bottom: 3mm;
letter-spacing: 0.3px;
}
.partner-list {
list-style: none;
font-size: 9.5pt;
line-height: 1.7;
}
.partner-list li {
padding-left: 16px;
position: relative;
}
.partner-list li::before {
content: "✓";
position: absolute;
left: 0;
font-weight: 700;
color: #fce4d3;
}
.qr-box {
background: white;
padding: 4mm;
text-align: center;
color: #1a2332;
}
.qr-placeholder {
width: 28mm;
height: 28mm;
background:
repeating-linear-gradient(0deg, #1a2332 0, #1a2332 2mm, white 2mm, white 3mm),
repeating-linear-gradient(90deg, #1a2332 0, #1a2332 2mm, white 2mm, white 3mm);
background-blend-mode: multiply;
margin: 0 auto 2mm auto;
border: 1px solid #1a2332;
display: flex;
align-items: center;
justify-content: center;
font-size: 7pt;
color: #5a6470;
text-align: center;
line-height: 1.2;
}
.qr-label {
font-size: 8pt;
font-weight: 600;
color: #c25b2e;
}
/* CTA */
.cta {
border: 2px solid #c25b2e;
padding: 5mm 6mm;
margin-bottom: 5mm;
}
.cta-title {
font-size: 11pt;
font-weight: 700;
color: #c25b2e;
margin-bottom: 2mm;
}
.cta-text {
font-size: 10pt;
color: #1a2332;
line-height: 1.55;
}
.cta-text strong {
color: #c25b2e;
}
/* FOOTER */
.footer {
border-top: 1px solid #e5d7cb;
padding-top: 4mm;
display: flex;
justify-content: space-between;
font-size: 8.5pt;
color: #5a6470;
}
.footer-contact strong {
color: #1a2332;
}
</style>
</head>
<body>
<div class="page">
<!-- HEADER -->
<div class="header">
<div class="brand">
<div class="brand-name">Banyaro</div>
<div class="brand-tagline">Die App, die Ihre Trainingsstunden verlängert</div>
</div>
<div class="header-right">
<div class="target-badge">Für Hundeschulen &amp; Trainer</div>
<div class="url">banyaro.app</div>
</div>
</div>
<!-- HEADLINE -->
<div class="headline">Damit das, was Sie in der Stunde aufbauen, zu Hause nicht verloren geht.</div>
<div class="intro">
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.
</div>
<!-- ZIELGRUPPE & NUTZEN -->
<div class="main-grid">
<div class="section">
<div class="section-title">Zielgruppe</div>
<ul class="section-list">
<li>Welpen- und Junghundebesitzer</li>
<li>Ersthundehalter mit hohem Lernbedarf</li>
<li>Engagierte Halter ab ca. 30 Jahren</li>
<li>Wiedereinsteiger nach längerer Pause</li>
<li>Sportlich oder thematisch ambitionierte Teams</li>
</ul>
</div>
<div class="section">
<div class="section-title">Nutzen für Ihre Schule</div>
<ul class="section-list">
<li>Kunden trainieren strukturierter zwischen den Stunden</li>
<li>Sichtbare Fortschritte = höhere Kundenbindung</li>
<li>Weniger „Was war das nochmal?“-Rückfragen</li>
<li>Eigenes Profil = Sichtbarkeit für Neukunden</li>
<li>Kostenfreier Mehrwert, den Sie weitergeben können</li>
</ul>
</div>
</div>
<!-- INHALTE -->
<div class="features">
<div class="features-title">Was Banyaro bietet</div>
<div class="feature-grid">
<div class="feature">
<div class="feature-name">Übungsbibliothek</div>
<div class="feature-desc">Strukturierte Anleitungen mit Schritten und Hilfestellungen</div>
</div>
<div class="feature">
<div class="feature-name">Trainingspläne</div>
<div class="feature-desc">Welpe, Grunderziehung, Leinenführung, Rückruf u.&nbsp;v.&nbsp;m.</div>
</div>
<div class="feature">
<div class="feature-name">Trainings-Tagebuch</div>
<div class="feature-desc">Halter dokumentieren Fortschritt &amp; Stimmung</div>
</div>
<div class="feature">
<div class="feature-name">Rassen-Wiki</div>
<div class="feature-desc">Über 900 Rassen mit Charakter- &amp; Erziehungs-Profilen</div>
</div>
<div class="feature">
<div class="feature-name">Erste Hilfe</div>
<div class="feature-desc">Notfall-Anleitungen — wichtig für Outdoor-Training</div>
</div>
<div class="feature">
<div class="feature-name">Gassi &amp; Treffen</div>
<div class="feature-desc">Routen, Treffpunkte, hundefreundliche Orte</div>
</div>
</div>
</div>
<!-- PARTNERSCHAFT -->
<div class="partner">
<div class="partner-content">
<div class="partner-title">Werden Sie Banyaro-Partnerschule</div>
<ul class="partner-list">
<li>Eigenes Schul-Profil in der App — auf der Karte verlinkt</li>
<li>QR-Code-Aufsteller für Theorieraum &amp; Anmeldung (kostenfrei)</li>
<li>Empfehlung an Ihre Kunden über unseren Halter-Newsletter</li>
<li>Affiliate-Beteiligung sobald die App monetarisiert wird</li>
</ul>
</div>
<div class="qr-box">
<div class="qr-placeholder">[QR-Code<br>banyaro.app]</div>
<div class="qr-label">Direkt ausprobieren</div>
</div>
</div>
<!-- CTA -->
<div class="cta">
<div class="cta-title">In 5 Minuten ausprobieren</div>
<div class="cta-text">
Banyaro ist eine Web-App — <strong>keine Installation aus dem App-Store nötig.</strong>
Öffnen Sie einfach <strong>banyaro.app</strong> 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.
</div>
</div>
<!-- FOOTER -->
<div class="footer">
<div class="footer-contact">
<strong>René [Nachname]</strong> &nbsp;·&nbsp; Entwickler Banyaro
</div>
<div class="footer-contact">
<strong>kontakt@banyaro.app</strong> &nbsp;·&nbsp; banyaro.app
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Banyaro für Tierärzte</title>
<style>
@page {
size: A4;
margin: 0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #1a2332;
background: white;
line-height: 1.5;
}
.page {
width: 210mm;
min-height: 297mm;
padding: 18mm 16mm 14mm 16mm;
background: white;
position: relative;
}
/* HEADER */
.header {
border-bottom: 3px solid #2d5f3f;
padding-bottom: 10mm;
margin-bottom: 8mm;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.brand {
display: flex;
flex-direction: column;
}
.brand-name {
font-size: 32pt;
font-weight: 700;
color: #2d5f3f;
letter-spacing: -0.5px;
line-height: 1;
}
.brand-tagline {
font-size: 11pt;
color: #5a6470;
margin-top: 4px;
font-weight: 400;
}
.header-right {
text-align: right;
}
.target-badge {
display: inline-block;
background: #2d5f3f;
color: white;
padding: 5px 14px;
font-size: 10pt;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.url {
font-size: 11pt;
color: #2d5f3f;
font-weight: 600;
margin-top: 6px;
}
/* HEADLINE */
.headline {
font-size: 18pt;
font-weight: 700;
color: #1a2332;
margin-bottom: 4mm;
line-height: 1.25;
}
.intro {
font-size: 10.5pt;
color: #3a4451;
margin-bottom: 7mm;
line-height: 1.55;
}
/* MAIN GRID */
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7mm;
margin-bottom: 7mm;
}
.section {
background: #f5f7f4;
border-left: 3px solid #2d5f3f;
padding: 5mm 5mm 5mm 5mm;
}
.section-title {
font-size: 11pt;
font-weight: 700;
color: #2d5f3f;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 3mm;
}
.section-list {
list-style: none;
font-size: 9.5pt;
color: #1a2332;
}
.section-list li {
padding: 2px 0 2px 14px;
position: relative;
line-height: 1.5;
}
.section-list li::before {
content: "▸";
position: absolute;
left: 0;
color: #2d5f3f;
font-weight: 700;
}
/* FEATURES */
.features {
margin-bottom: 7mm;
}
.features-title {
font-size: 12pt;
font-weight: 700;
color: #1a2332;
margin-bottom: 4mm;
padding-bottom: 2mm;
border-bottom: 1px solid #d4dbd4;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4mm;
}
.feature {
font-size: 9.5pt;
}
.feature-name {
font-weight: 700;
color: #2d5f3f;
margin-bottom: 2px;
}
.feature-desc {
color: #3a4451;
line-height: 1.4;
}
/* PARTNERSCHAFT */
.partner {
background: linear-gradient(135deg, #2d5f3f 0%, #3a7752 100%);
color: white;
padding: 6mm;
margin-bottom: 6mm;
display: grid;
grid-template-columns: 1fr 38mm;
gap: 6mm;
align-items: center;
}
.partner-content {
color: white;
}
.partner-title {
font-size: 13pt;
font-weight: 700;
margin-bottom: 3mm;
letter-spacing: 0.3px;
}
.partner-list {
list-style: none;
font-size: 9.5pt;
line-height: 1.7;
}
.partner-list li {
padding-left: 16px;
position: relative;
}
.partner-list li::before {
content: "✓";
position: absolute;
left: 0;
font-weight: 700;
color: #c8e6c9;
}
.qr-box {
background: white;
padding: 4mm;
text-align: center;
color: #1a2332;
}
.qr-placeholder {
width: 28mm;
height: 28mm;
background:
repeating-linear-gradient(0deg, #1a2332 0, #1a2332 2mm, white 2mm, white 3mm),
repeating-linear-gradient(90deg, #1a2332 0, #1a2332 2mm, white 2mm, white 3mm);
background-blend-mode: multiply;
margin: 0 auto 2mm auto;
border: 1px solid #1a2332;
display: flex;
align-items: center;
justify-content: center;
font-size: 7pt;
color: #5a6470;
text-align: center;
line-height: 1.2;
}
.qr-label {
font-size: 8pt;
font-weight: 600;
color: #2d5f3f;
}
/* CTA */
.cta {
border: 2px solid #2d5f3f;
padding: 5mm 6mm;
margin-bottom: 5mm;
}
.cta-title {
font-size: 11pt;
font-weight: 700;
color: #2d5f3f;
margin-bottom: 2mm;
}
.cta-text {
font-size: 10pt;
color: #1a2332;
line-height: 1.55;
}
.cta-text strong {
color: #2d5f3f;
}
/* FOOTER */
.footer {
border-top: 1px solid #d4dbd4;
padding-top: 4mm;
display: flex;
justify-content: space-between;
font-size: 8.5pt;
color: #5a6470;
}
.footer-contact strong {
color: #1a2332;
}
</style>
</head>
<body>
<div class="page">
<!-- HEADER -->
<div class="header">
<div class="brand">
<div class="brand-name">Banyaro</div>
<div class="brand-tagline">Die App für ein gesundes Hundeleben</div>
</div>
<div class="header-right">
<div class="target-badge">Für Tierärztinnen &amp; Tierärzte</div>
<div class="url">banyaro.app</div>
</div>
</div>
<!-- HEADLINE -->
<div class="headline">Eine digitale Gesundheitsakte, die Ihre Patientenbesitzer wirklich nutzen.</div>
<div class="intro">
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.
</div>
<!-- ZIELGRUPPE & NUTZEN -->
<div class="main-grid">
<div class="section">
<div class="section-title">Zielgruppe</div>
<ul class="section-list">
<li>Engagierte Hundehalter ab ca. 30 Jahren</li>
<li>Familien mit Ersthund</li>
<li>Halter chronisch oder mehrfach erkrankter Tiere</li>
<li>Sicherheitsbewusste Gassigeher (Giftköder-Karte)</li>
<li>Züchter &amp; Mehrhundehaushalte</li>
</ul>
</div>
<div class="section">
<div class="section-title">Nutzen für Ihre Praxis</div>
<ul class="section-list">
<li>Strukturiertere Anamnesen durch geführte Halter</li>
<li>Vollständige Impf- und Medikationshistorie</li>
<li>Frühere Vorstellung dank Symptom-Tracking</li>
<li>Entlastung bei Standardfragen (Erste Hilfe, Gift)</li>
<li>Mehrwert für Ihre Klienten — kostenlos</li>
</ul>
</div>
</div>
<!-- INHALTE -->
<div class="features">
<div class="features-title">Was Banyaro bietet</div>
<div class="feature-grid">
<div class="feature">
<div class="feature-name">Gesundheitsakte</div>
<div class="feature-desc">Impfungen, Medikamente, Befunde, Gewicht — exportierbar</div>
</div>
<div class="feature">
<div class="feature-name">Erste-Hilfe-Bereich</div>
<div class="feature-desc">11 Notfall-Anleitungen, fachlich geprüft</div>
</div>
<div class="feature">
<div class="feature-name">Giftköder-Karte</div>
<div class="feature-desc">Community-gepflegte Warnkarte mit Echtzeit-Meldungen</div>
</div>
<div class="feature">
<div class="feature-name">Rassen-Wiki</div>
<div class="feature-desc">Über 900 Rassen mit Gesundheits-Profilen</div>
</div>
<div class="feature">
<div class="feature-name">Trainingsbereich</div>
<div class="feature-desc">Übungsbibliothek &amp; strukturierte Trainingspläne</div>
</div>
<div class="feature">
<div class="feature-name">Gassi-Funktionen</div>
<div class="feature-desc">Routen, Treffpunkte, hundefreundliche Orte</div>
</div>
</div>
</div>
<!-- PARTNERSCHAFT -->
<div class="partner">
<div class="partner-content">
<div class="partner-title">Werden Sie Banyaro-Partnerpraxis</div>
<ul class="partner-list">
<li>Eigenes Praxisprofil in der App — verlinkt aus der Karte</li>
<li>QR-Code-Aufsteller fürs Wartezimmer (kostenfrei zugesendet)</li>
<li>Erwähnung in unserem Halter-Newsletter als Partnerpraxis</li>
<li>Affiliate-Beteiligung sobald die App monetarisiert wird</li>
</ul>
</div>
<div class="qr-box">
<div class="qr-placeholder">[QR-Code<br>banyaro.app]</div>
<div class="qr-label">Direkt ausprobieren</div>
</div>
</div>
<!-- CTA -->
<div class="cta">
<div class="cta-title">In 5 Minuten ausprobieren</div>
<div class="cta-text">
Banyaro ist eine Web-App — <strong>keine Installation aus dem App-Store nötig.</strong>
Einfach im Browser <strong>banyaro.app</strong> ö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.
</div>
</div>
<!-- FOOTER -->
<div class="footer">
<div class="footer-contact">
<strong>René [Nachname]</strong> &nbsp;·&nbsp; Entwickler Banyaro
</div>
<div class="footer-contact">
<strong>kontakt@banyaro.app</strong> &nbsp;·&nbsp; banyaro.app
</div>
</div>
</div>
</body>
</html>