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:
parent
95f91fdc00
commit
553e9e7854
35 changed files with 4558 additions and 370 deletions
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"sessionId":"39ad9ffb-6cac-40b2-8c2d-b3974db3a4b8","pid":1946,"procStart":"Sat Apr 25 14:22:02 2026","acquiredAt":1777133270339}
|
||||
46
PROJEKT.md
46
PROJEKT.md
|
|
@ -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)
|
||||
|
||||
### Sprint 11 (2026-04-25) ✅
|
||||
|
|
@ -222,7 +266,7 @@ Maps: Leaflet.js + OpenStreetMap (kostenlos, kein Google-Lock)
|
|||
#### 1.2 Gesundheit & Impfpass
|
||||
- [ ] Impfungen, Entwurmungen, Tierarztbesuche digital
|
||||
- [ ] Medikamenten-Reminder (Push Notification)
|
||||
- [ ] Gewichtsverlauf-Chart
|
||||
- [x] Gewichtsverlauf-Chart ✅
|
||||
- [ ] Einfacher Symptom-Checker (KI-gestützt, Triage: beobachten/Tierarzt/Notfall)
|
||||
|
||||
#### 1.3 Giftköder-Alarm
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ def get_current_user(
|
|||
user_id = int(payload["sub"])
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason 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,)
|
||||
).fetchone()
|
||||
|
||||
|
|
|
|||
|
|
@ -534,6 +534,13 @@ def _migrate(conn_factory):
|
|||
("pflege_tipps", "fell_pflege_art", "TEXT"),
|
||||
# Wiki-Foto-Einreichungen: Bildrechte-Bestätigung
|
||||
("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"),
|
||||
# Tagebuch: Wetter + POI-Metadaten beim Eintrag
|
||||
("diary", "weather_json", "TEXT"),
|
||||
("diary", "poi_json", "TEXT"),
|
||||
# Notizen: Ort + Label + KI-Assistent User-Setting
|
||||
("notes", "location_name", "TEXT"),
|
||||
("notes", "parent_label", "TEXT"),
|
||||
("users", "notes_ki_enabled", "INTEGER NOT NULL DEFAULT 1"),
|
||||
]
|
||||
with conn_factory() as conn:
|
||||
for table, column, col_type in migrations:
|
||||
|
|
@ -1131,3 +1138,22 @@ def _migrate(conn_factory):
|
|||
CREATE INDEX IF NOT EXISTS idx_ki_daily_source ON ki_daily_calls(date, source);
|
||||
""")
|
||||
logger.info("Migration: ki_daily_calls.source bereit.")
|
||||
|
||||
# Notizen: generische polymorphe Notiz-Tabelle
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
parent_type TEXT NOT NULL,
|
||||
parent_id INTEGER NOT NULL,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
meta_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_parent
|
||||
ON notes(parent_type, parent_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_user
|
||||
ON notes(user_id, created_at DESC);
|
||||
""")
|
||||
logger.info("Migration: notes Tabelle bereit.")
|
||||
|
|
|
|||
|
|
@ -80,6 +80,24 @@ class _UploadSizeMiddleware(BaseHTTPMiddleware):
|
|||
app.add_middleware(_UploadSizeMiddleware)
|
||||
|
||||
|
||||
class _CacheControlMiddleware(BaseHTTPMiddleware):
|
||||
"""Setzt Cache-Control-Header für statische Assets.
|
||||
CSS/JS: no-cache (ETag-Validierung) — iOS cached sonst ewig ohne Ablaufdatum.
|
||||
Versioned Assets (?v=…): immutable — URL ändert sich bei Updates.
|
||||
"""
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
path = request.url.path
|
||||
if path.startswith(("/css/", "/js/", "/icons/phosphor.svg")):
|
||||
if "v=" in str(request.url.query):
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
else:
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return response
|
||||
|
||||
app.add_middleware(_CacheControlMiddleware)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API-Router registrieren (werden nach und nach hinzugefügt)
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -122,6 +140,7 @@ from routes.praise import router as praise_router
|
|||
from routes.weather import router as weather_router
|
||||
from routes.social import router as social_router
|
||||
from routes.moderation import router as moderation_router
|
||||
from routes.notes import router as notes_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||
|
|
@ -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(praise_router, prefix="/api/praise", tags=["Praise"])
|
||||
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
|
||||
app.include_router(notes_router, prefix="/api/notes", tags=["Notes"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -117,6 +117,36 @@ def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]:
|
|||
pass
|
||||
|
||||
|
||||
def extract_gps_from_exif(data: bytes) -> tuple | None:
|
||||
"""EXIF-GPS aus Bilddaten lesen. Gibt (lat, lon) zurück oder None."""
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(io.BytesIO(data))
|
||||
exif = img._getexif()
|
||||
if not exif:
|
||||
return None
|
||||
gps = exif.get(34853) # GPSInfo tag
|
||||
if not gps:
|
||||
return None
|
||||
lat_dms = gps.get(2)
|
||||
lon_dms = gps.get(4)
|
||||
lat_ref = gps.get(1, 'N')
|
||||
lon_ref = gps.get(3, 'E')
|
||||
if not lat_dms or not lon_dms:
|
||||
return None
|
||||
|
||||
def dms(v):
|
||||
return float(v[0]) + float(v[1]) / 60 + float(v[2]) / 3600
|
||||
|
||||
lat = dms(lat_dms) * (-1 if lat_ref == 'S' else 1)
|
||||
lon = dms(lon_dms) * (-1 if lon_ref == 'W' else 1)
|
||||
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
|
||||
return None
|
||||
return round(lat, 6), round(lon, 6)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def convert_media(data: bytes, filename: str) -> Tuple[bytes, str]:
|
||||
"""Convert HEIC→JPEG and MOV/AVI/M4V→MP4; pass everything else through."""
|
||||
ext = os.path.splitext(filename or "")[1].lower()
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
"""BAN YARO — Tagebuch Routes"""
|
||||
|
||||
import os, uuid, json, math
|
||||
import os, uuid, json, math, logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
from auth import get_current_user, require_admin
|
||||
import ki as KI
|
||||
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()
|
||||
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
|
||||
|
||||
|
||||
@router.get("/{dog_id}/diary/stats")
|
||||
async def diary_stats(dog_id: int, user=Depends(get_current_user)):
|
||||
"""Gesamtstatistik für das Tagebuch (unabhängig von Pagination)."""
|
||||
with db() as conn:
|
||||
_can_read_dog(dog_id, user["id"], conn)
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
|
||||
"WHERE (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
|
||||
).fetchone()[0]
|
||||
photos = conn.execute(
|
||||
"SELECT COUNT(*) FROM diary_media dm "
|
||||
"JOIN diary d ON d.id=dm.diary_id LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
|
||||
"WHERE (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
|
||||
).fetchone()[0]
|
||||
days = conn.execute(
|
||||
"SELECT COUNT(DISTINCT d.datum) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
|
||||
"WHERE d.datum IS NOT NULL AND (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
|
||||
).fetchone()[0]
|
||||
return {"entries": total, "photos": photos, "days": days}
|
||||
|
||||
|
||||
@router.get("/{dog_id}/diary/calendar")
|
||||
async def diary_calendar(dog_id: int, user=Depends(get_current_user)):
|
||||
"""Alle Einträge minimal für Kalenderansicht: id, datum, cover_url."""
|
||||
with db() as conn:
|
||||
_can_read_dog(dog_id, user["id"], conn)
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT d.id, d.datum,
|
||||
(SELECT dm.url FROM diary_media dm
|
||||
WHERE dm.diary_id=d.id AND dm.media_type='image'
|
||||
ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url
|
||||
FROM diary d
|
||||
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
|
||||
WHERE (d.dog_id=? OR dd.dog_id=?)
|
||||
AND d.datum IS NOT NULL
|
||||
ORDER BY d.datum DESC""",
|
||||
(dog_id, dog_id)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{dog_id}/diary/locations")
|
||||
async def diary_locations(dog_id: int, user=Depends(get_current_user)):
|
||||
"""Alle Tagebucheinträge mit GPS — minimal für Karten-Ansicht."""
|
||||
with db() as conn:
|
||||
_can_read_dog(dog_id, user["id"], conn)
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT d.id, d.datum, d.titel, d.gps_lat, d.gps_lon,
|
||||
d.location_name, d.weather_json,
|
||||
(SELECT dm.url FROM diary_media dm
|
||||
WHERE dm.diary_id=d.id AND dm.media_type='image'
|
||||
ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url,
|
||||
(SELECT COUNT(*) FROM diary_media dm WHERE dm.diary_id=d.id) AS media_count
|
||||
FROM diary d
|
||||
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
|
||||
WHERE (d.dog_id=? OR dd.dog_id=?)
|
||||
AND d.gps_lat IS NOT NULL AND d.gps_lon IS NOT NULL
|
||||
ORDER BY d.datum DESC""",
|
||||
(dog_id, dog_id)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{dog_id}/diary")
|
||||
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
|
||||
q: Optional[str] = None, milestone: int = 0,
|
||||
|
|
@ -226,10 +292,95 @@ async def create_diary(dog_id: int, data: DiaryCreate,
|
|||
_set_dog_ids(conn, entry["id"], all_dogs)
|
||||
dogs_map = _fetch_dog_ids(conn, [entry["id"]])
|
||||
media_map = _fetch_media_items(conn, [entry["id"]])
|
||||
entry_id = entry["id"]
|
||||
|
||||
# Wetter + POIs asynchron nach dem DB-Commit holen (außerhalb des with-Blocks)
|
||||
if data.gps_lat is not None and data.gps_lon is not None:
|
||||
weather_json = None
|
||||
poi_json = None
|
||||
|
||||
# Wetter holen
|
||||
try:
|
||||
wd = await weather_mod.get_weather_for_location(data.gps_lat, data.gps_lon)
|
||||
weather_json = json.dumps(wd)
|
||||
except Exception as exc:
|
||||
logger.warning("Wetter-Abfrage beim Diary-Create fehlgeschlagen: %s", exc)
|
||||
|
||||
# POIs holen
|
||||
try:
|
||||
pois = await _fetch_pois_for_coords(data.gps_lat, data.gps_lon, limit=5)
|
||||
if pois:
|
||||
poi_json = json.dumps(pois)
|
||||
except Exception as exc:
|
||||
logger.warning("POI-Abfrage beim Diary-Create fehlgeschlagen: %s", exc)
|
||||
|
||||
# In DB speichern und Entry aktualisieren
|
||||
if weather_json is not None or poi_json is not None:
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE diary SET weather_json=?, poi_json=? WHERE id=?",
|
||||
(weather_json, poi_json, entry_id)
|
||||
)
|
||||
entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
|
||||
|
||||
return _entry_dict(entry, dogs_map, media_map)
|
||||
|
||||
|
||||
async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list:
|
||||
"""Holt POIs für Koordinaten via Overpass (analog zu nearby_places, aber ohne DB/Auth)."""
|
||||
results = []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=6) as client:
|
||||
def _overpass_q(radius):
|
||||
return (
|
||||
f'[out:json][timeout:6];'
|
||||
f'('
|
||||
f' node["name"]["tourism"](around:{radius},{lat},{lon});'
|
||||
f' node["name"]["historic"](around:{radius},{lat},{lon});'
|
||||
f' node["name"]["leisure"](around:{radius},{lat},{lon});'
|
||||
f' node["name"]["amenity"](around:{radius},{lat},{lon});'
|
||||
f' node["name"]["shop"](around:{radius},{lat},{lon});'
|
||||
f' way["name"]["tourism"](around:{radius},{lat},{lon});'
|
||||
f' way["name"]["historic"](around:{radius},{lat},{lon});'
|
||||
f' way["name"]["leisure"](around:{radius},{lat},{lon});'
|
||||
f');'
|
||||
f'out center;'
|
||||
)
|
||||
ov = await client.post(
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
data={"data": _overpass_q(800)},
|
||||
headers={"User-Agent": "BanYaro/1.0"},
|
||||
)
|
||||
elements = ov.json().get("elements", []) if ov.status_code == 200 else []
|
||||
if not elements:
|
||||
ov2 = await client.post(
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
data={"data": _overpass_q(2000)},
|
||||
headers={"User-Agent": "BanYaro/1.0"},
|
||||
)
|
||||
elements = ov2.json().get("elements", []) if ov2.status_code == 200 else []
|
||||
seen = set()
|
||||
for el in elements:
|
||||
n = el.get("tags", {}).get("name")
|
||||
if not n or n.lower() in seen:
|
||||
continue
|
||||
seen.add(n.lower())
|
||||
elat = el.get("lat") or el.get("center", {}).get("lat")
|
||||
elon = el.get("lon") or el.get("center", {}).get("lon")
|
||||
if elat and elon:
|
||||
km = _haversine_km(lat, lon, elat, elon)
|
||||
typ = next((el["tags"].get(k) for k in
|
||||
["tourism", "historic", "leisure", "amenity", "shop"]
|
||||
if el["tags"].get(k)), "place")
|
||||
results.append({"name": n, "type": typ,
|
||||
"distance_m": int(km * 1000)})
|
||||
if len(results) >= limit:
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.debug("_fetch_pois_for_coords Fehler: %s", exc)
|
||||
return results[:limit]
|
||||
|
||||
|
||||
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
||||
R = 6371
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
|
|
@ -508,6 +659,11 @@ async def upload_media(dog_id: int, entry_id: int,
|
|||
|
||||
media_url = f"/media/diary/{filename}"
|
||||
|
||||
# EXIF-GPS aus Bild extrahieren (nur bei Bilddateien)
|
||||
exif_gps = None
|
||||
if media_type == "image":
|
||||
exif_gps = extract_gps_from_exif(raw_data)
|
||||
|
||||
with db() as conn:
|
||||
# sort_order = nächste freie Position
|
||||
max_order = conn.execute(
|
||||
|
|
@ -525,8 +681,38 @@ async def upload_media(dog_id: int, entry_id: int,
|
|||
(entry_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}
|
||||
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)
|
||||
|
|
@ -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=1 WHERE id=?", (media_id,))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Admin: retroaktive Metadaten-Anreicherung bestehender Einträge
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/admin/enrich-metadata", status_code=200)
|
||||
async def admin_enrich_diary_metadata(limit: int = 20, _=Depends(require_admin)):
|
||||
"""Reichert bestehende Tagebucheinträge mit GPS-Koordinaten mit Wetter + POI nach."""
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, gps_lat, gps_lon FROM diary
|
||||
WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL
|
||||
AND (weather_json IS NULL OR poi_json IS NULL)
|
||||
LIMIT ?""",
|
||||
(limit,)
|
||||
).fetchall()
|
||||
|
||||
enriched = 0
|
||||
skipped = 0
|
||||
for row in rows:
|
||||
entry_id, lat, lon = row["id"], row["gps_lat"], row["gps_lon"]
|
||||
weather_json = None
|
||||
poi_json = None
|
||||
try:
|
||||
wd = await weather_mod.get_weather_for_location(lat, lon)
|
||||
weather_json = json.dumps(wd)
|
||||
except Exception as e:
|
||||
logger.warning("enrich-metadata Wetter id=%s: %s", entry_id, e)
|
||||
try:
|
||||
pois = await _fetch_pois_for_coords(lat, lon, limit=5)
|
||||
if pois:
|
||||
poi_json = json.dumps(pois)
|
||||
except Exception as e:
|
||||
logger.warning("enrich-metadata POI id=%s: %s", entry_id, e)
|
||||
|
||||
if weather_json is not None or poi_json is not None:
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE diary SET weather_json=COALESCE(weather_json,?), poi_json=COALESCE(poi_json,?) WHERE id=?",
|
||||
(weather_json, poi_json, entry_id)
|
||||
)
|
||||
enriched += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
with db() as conn:
|
||||
remaining = conn.execute(
|
||||
"""SELECT COUNT(*) FROM diary
|
||||
WHERE gps_lat IS NOT NULL AND (weather_json IS NULL OR poi_json IS NULL)"""
|
||||
).fetchone()[0]
|
||||
|
||||
return {"enriched": enriched, "skipped": skipped, "remaining": remaining}
|
||||
|
|
|
|||
|
|
@ -155,9 +155,9 @@ async def import_notestation(
|
|||
(entry_id, dog_id),
|
||||
)
|
||||
|
||||
# Erstes Bild speichern
|
||||
# Anhänge in diary_media speichern (statt veraltetem media_url-Feld)
|
||||
attachments = note.get("attachment") or {}
|
||||
media_url = None
|
||||
first = True
|
||||
for att in attachments.values():
|
||||
md5 = att.get("md5", "")
|
||||
mime = att.get("type", "image/jpeg")
|
||||
|
|
@ -165,13 +165,11 @@ async def import_notestation(
|
|||
continue
|
||||
media_url = _save_image_from_zip(zf, md5, mime)
|
||||
if media_url:
|
||||
break
|
||||
|
||||
if media_url:
|
||||
conn.execute(
|
||||
"UPDATE diary SET media_url=? WHERE id=?",
|
||||
(media_url, entry_id),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)",
|
||||
(entry_id, media_url, "image", 0 if first else 1, 1 if first else 0),
|
||||
)
|
||||
first = False
|
||||
|
||||
imported += 1
|
||||
|
||||
|
|
|
|||
263
backend/routes/notes.py
Normal file
263
backend/routes/notes.py
Normal 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
|
||||
|
|
@ -25,6 +25,7 @@ class ProfileUpdate(BaseModel):
|
|||
erfahrung: Optional[str] = None
|
||||
social_link: Optional[str] = None
|
||||
profil_sichtbarkeit: Optional[str] = None
|
||||
notes_ki_enabled: Optional[int] = None
|
||||
|
||||
|
||||
def _load_user(user_id: int) -> dict:
|
||||
|
|
|
|||
|
|
@ -90,14 +90,6 @@ def start():
|
|||
id="seed_wikidata_startup",
|
||||
replace_existing=True,
|
||||
)
|
||||
# Täglich 02:30 Uhr — KI-Anreicherung für 20 noch nicht angereicherte Rassen
|
||||
_scheduler.add_job(
|
||||
_job_wiki_enrich,
|
||||
CronTrigger(hour=2, minute=30),
|
||||
id="wiki_enrich_nightly",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
# Jeden Montag 09:00 — Wöchentlicher Fortschritts-Lober
|
||||
_scheduler.add_job(
|
||||
_job_weekly_praise,
|
||||
|
|
@ -114,16 +106,8 @@ def start():
|
|||
replace_existing=True,
|
||||
misfire_grace_time=1800,
|
||||
)
|
||||
# Einmalig beim Start (nach 90s) — erste 50 Rassen sofort anreichern
|
||||
_scheduler.add_job(
|
||||
_job_wiki_enrich_startup,
|
||||
'date',
|
||||
run_date=datetime.now(tz=_TZ) + timedelta(seconds=90),
|
||||
id="wiki_enrich_startup",
|
||||
replace_existing=True,
|
||||
)
|
||||
_scheduler.start()
|
||||
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start, 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():
|
||||
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -815,12 +770,6 @@ async def _job_status_report():
|
|||
metrics = {}
|
||||
try:
|
||||
with db() as conn:
|
||||
# Rassen-Anreicherung
|
||||
metrics["rassen_total"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen").fetchone()[0]
|
||||
metrics["rassen_enriched"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=1").fetchone()[0]
|
||||
metrics["rassen_mit_foto"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE foto_url IS NOT NULL AND foto_url NOT LIKE 'http%'").fetchone()[0]
|
||||
metrics["rassen_mit_desc"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE beschreibung IS NOT NULL AND beschreibung != ''").fetchone()[0]
|
||||
|
||||
# Züchter
|
||||
try:
|
||||
metrics["zuchter_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0").fetchone()[0]
|
||||
|
|
@ -848,16 +797,6 @@ async def _job_status_report():
|
|||
logger.error(f"Status-Report: DB-Fehler: {e}")
|
||||
return
|
||||
|
||||
# --- Wiki-Fortschritt berechnen ---
|
||||
total = metrics["rassen_total"] or 1
|
||||
enriched = metrics["rassen_enriched"]
|
||||
pct = round(enriched / total * 100)
|
||||
remaining = total - enriched
|
||||
nights_left = (remaining + 19) // 20 # bei 20/Nacht
|
||||
|
||||
bar_filled = round(pct / 5)
|
||||
progress_bar = "█" * bar_filled + "░" * (20 - bar_filled)
|
||||
|
||||
# --- Job-Log-Tabelle ---
|
||||
job_labels = {
|
||||
"health_reminders": "Gesundheits-Erinnerungen",
|
||||
|
|
@ -865,8 +804,6 @@ async def _job_status_report():
|
|||
"weather_alert": "Wetter-Alert",
|
||||
"milestone_check": "Meilenstein-Check",
|
||||
"import_events": "Event-Import (VDH)",
|
||||
"wiki_enrich_nightly": "Wiki KI-Anreicherung (nächtlich)",
|
||||
"wiki_enrich_startup": "Wiki KI-Anreicherung (Startup)",
|
||||
"seed_breeds_startup": "Rassen-Seed (TheDogAPI)",
|
||||
"seed_wikidata_startup":"Rassen-Seed (Wikidata)",
|
||||
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
|
||||
|
|
@ -899,18 +836,6 @@ async def _job_status_report():
|
|||
<div style="opacity:.88;font-size:13px">{now_str} Uhr</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 -->
|
||||
<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>
|
||||
|
|
@ -947,13 +872,6 @@ async def _job_status_report():
|
|||
|
||||
plain = f"""Ban Yaro Status-Report — {now_str}
|
||||
|
||||
=== Wiki KI-Anreicherung ===
|
||||
{progress_bar} {pct}%
|
||||
Angereichert: {enriched}/{total}
|
||||
Verbleibend: {remaining} Rassen (~{nights_left} Nächte à 20/Nacht)
|
||||
Mit Foto: {metrics['rassen_mit_foto']}
|
||||
Mit Beschreibung: {metrics['rassen_mit_desc']}
|
||||
|
||||
=== Scheduler-Jobs ===
|
||||
{job_rows_txt}
|
||||
=== Community ===
|
||||
|
|
|
|||
|
|
@ -963,82 +963,326 @@ html.modal-open {
|
|||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
12. TAGEBUCH
|
||||
12. TAGEBUCH — Day One Style
|
||||
------------------------------------------------------------ */
|
||||
|
||||
/* Monats-Trennlinie */
|
||||
.diary-month-header {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--c-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: var(--space-4) 0 var(--space-2);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
margin-bottom: var(--space-3);
|
||||
/* Stats-Leiste */
|
||||
.diary-stats-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--c-divider, var(--c-border));
|
||||
background: var(--c-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.diary-month-header:first-child {
|
||||
padding-top: 0;
|
||||
.diary-stats-numbers {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.diary-stats-numbers::-webkit-scrollbar { display: none; }
|
||||
.diary-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
border-right: 1px solid var(--c-border);
|
||||
}
|
||||
.diary-stat:last-child { border-right: none; }
|
||||
.diary-stat-num {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--c-text);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.diary-stat-label {
|
||||
font-size: 9px;
|
||||
color: var(--c-text-muted);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--space-3);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
box-shadow: var(--shadow-xs);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--c-divider, var(--c-border));
|
||||
border-radius: 0;
|
||||
margin-bottom: 0;
|
||||
overflow: visible;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
box-shadow: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.diary-card:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.diary-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
background: rgba(0,0,0,0.025);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
.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 */
|
||||
.diary-card--milestone {
|
||||
border-color: #d4a017;
|
||||
border-width: 2px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--c-surface) 0%,
|
||||
color-mix(in srgb, #d4a017 8%, var(--c-surface)) 100%
|
||||
);
|
||||
background: color-mix(in srgb, #d4a017 4%, transparent);
|
||||
}
|
||||
.diary-card--milestone .diary-card-daynum {
|
||||
color: #b8860b;
|
||||
}
|
||||
|
||||
/* Meilenstein-Badge innerhalb der Karte */
|
||||
.diary-card-milestone-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: color-mix(in srgb, #d4a017 15%, transparent);
|
||||
color: #8a6400;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
margin-bottom: var(--space-2);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: color-mix(in srgb, #d4a017 15%, transparent);
|
||||
color: #8a6400;
|
||||
font-weight: 600;
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-full);
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Foto / Video oben */
|
||||
/* Foto / Thumbnail rechts — 72×72px */
|
||||
.diary-card-photo {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
overflow: hidden;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.diary-card-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.diary-media-picker {
|
||||
display: flex;
|
||||
|
|
@ -1165,7 +1409,7 @@ html.modal-open {
|
|||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0,0,0,.50);
|
||||
color: #9ca3af;
|
||||
color: rgba(255,255,255,.55);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
|
|
@ -1177,7 +1421,7 @@ html.modal-open {
|
|||
transition: color .15s, background .15s;
|
||||
}
|
||||
.diary-cover-btn--active {
|
||||
color: #f5c518;
|
||||
color: var(--c-amber);
|
||||
background: rgba(0,0,0,.65);
|
||||
}
|
||||
.diary-cover-btn--form {
|
||||
|
|
@ -1185,48 +1429,46 @@ html.modal-open {
|
|||
left: var(--space-1);
|
||||
}
|
||||
|
||||
/* Card Body */
|
||||
/* Card Body — mittlere Spalte */
|
||||
.diary-card-body {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Meta-Zeile: Typ + Datum */
|
||||
.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 */
|
||||
/* Titel in Karte */
|
||||
.diary-card-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--c-text);
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--c-text);
|
||||
margin: 0 0 3px;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Meta-Zeile: nur noch für Compat — im neuen Design nicht als flex-row genutzt */
|
||||
.diary-card-meta {
|
||||
display: none;
|
||||
}
|
||||
.diary-card-type { display: none; }
|
||||
.diary-card-date { display: none; }
|
||||
|
||||
/* Ort-Zeile in Karte */
|
||||
.diary-card-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-primary);
|
||||
margin: 0 0 var(--space-1);
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--c-text-muted);
|
||||
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 */
|
||||
.diary-detail-location {
|
||||
|
|
@ -1292,12 +1534,12 @@ html.modal-open {
|
|||
|
||||
/* Text-Vorschau */
|
||||
.diary-card-text {
|
||||
font-size: var(--text-sm);
|
||||
font-size: 13px;
|
||||
color: var(--c-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 var(--space-2);
|
||||
line-height: 1.45;
|
||||
margin: 0 0 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -1310,6 +1552,62 @@ html.modal-open {
|
|||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* Meta-Zeile unten in der Karte: Zeit · Ort · Wetter */
|
||||
.diary-card-meta-row {
|
||||
font-size: 12px;
|
||||
color: var(--c-text-muted);
|
||||
line-height: 1.4;
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Wetter-Badge in Karten-Meta */
|
||||
.diary-weather-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--c-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* FAB — Floating Action Button */
|
||||
.diary-fab {
|
||||
position: fixed;
|
||||
bottom: calc(var(--nav-bottom-height, 64px) + env(safe-area-inset-bottom, 0px) + 16px);
|
||||
right: 20px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--c-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(196,132,58,.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.diary-fab:hover { transform: scale(1.06); box-shadow: 0 6px 20px rgba(196,132,58,.5); }
|
||||
.diary-fab:active { transform: scale(0.94); }
|
||||
|
||||
/* POI-Chips in Karte und Detail */
|
||||
.diary-poi-chips,
|
||||
.diary-detail-poi-chips {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--c-text-muted);
|
||||
line-height: 1.5;
|
||||
margin: var(--space-1) 0 var(--space-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Detail-Ansicht */
|
||||
.diary-detail-milestone-badge {
|
||||
display: inline-flex;
|
||||
|
|
@ -1324,6 +1622,312 @@ html.modal-open {
|
|||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
/* Detail-View: Hero-Bild */
|
||||
.diary-detail-hero {
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
background: #000;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.diary-detail-hero {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
border-radius: 0 0 12px 12px;
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
.diary-detail-hero { max-width: 1300px; }
|
||||
}
|
||||
.diary-detail-hero img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.diary-detail-hero video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Detail-View: inline im Content-Bereich (kein Overlay mehr) */
|
||||
.diary-detail-view-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 120px);
|
||||
background: var(--c-bg);
|
||||
}
|
||||
|
||||
/* Detail-View: Header-Bar */
|
||||
.diary-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--c-surface);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
flex-shrink: 0;
|
||||
min-height: 48px;
|
||||
}
|
||||
.diary-detail-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--c-primary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.diary-detail-date-center {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--c-text);
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
padding: 0 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.diary-detail-edit {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--c-primary);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Detail-View: Body-Wrapper (text links, Karte rechts auf Desktop) */
|
||||
.diary-detail-body-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.diary-detail-body-wrap {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
.diary-detail-body-wrap { max-width: 1300px; }
|
||||
}
|
||||
|
||||
/* Detail-View: Inhalt */
|
||||
.diary-detail-content {
|
||||
padding: 24px 24px 60px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.diary-detail-content { padding: 20px 16px 40px; }
|
||||
}
|
||||
|
||||
/* Detail-View: Karte + POI-Sektion */
|
||||
.diary-detail-map-wrap {
|
||||
padding: 16px 16px 40px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.diary-detail-map-wrap {
|
||||
width: 380px;
|
||||
min-width: 300px;
|
||||
max-width: 420px;
|
||||
flex-shrink: 0;
|
||||
padding: 24px 0 40px 32px;
|
||||
position: sticky;
|
||||
top: 60px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
.diary-detail-map {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--c-border);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.diary-detail-map { height: 280px; }
|
||||
}
|
||||
|
||||
/* POI-Liste */
|
||||
.diary-detail-poi-list {
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.diary-detail-poi-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--c-text-muted);
|
||||
padding: 10px 14px 8px;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
.diary-detail-poi-heading .ph-icon { width:14px;height:14px; }
|
||||
.diary-detail-poi-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.diary-detail-poi-row:last-child { border-bottom: none; }
|
||||
.diary-detail-poi-icon { width:16px;height:16px;color:var(--c-primary);flex-shrink:0; }
|
||||
.diary-detail-poi-name { flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c-text); }
|
||||
.diary-detail-poi-dist { font-size:12px;color:var(--c-text-muted);flex-shrink:0; }
|
||||
.diary-detail-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--c-text);
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.diary-detail-body {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: var(--c-text);
|
||||
white-space: pre-wrap;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.diary-detail-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--c-border);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* Detail-View: Meta-Bar unten */
|
||||
.diary-detail-meta-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--c-text-muted);
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.diary-detail-meta-bar .ph-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.diary-detail-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* Detail-View: Thumbnail-Strip */
|
||||
.diary-detail-thumbs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 6px 16px;
|
||||
overflow-x: auto;
|
||||
background: rgba(0,0,0,.6);
|
||||
flex-shrink: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.diary-detail-thumbs {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
border-radius: 0 0 8px 8px;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
background: rgba(0,0,0,.75);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
.diary-detail-thumbs { max-width: 1300px; }
|
||||
}
|
||||
.diary-detail-thumbs::-webkit-scrollbar { display: none; }
|
||||
.diary-detail-thumb {
|
||||
flex-shrink: 0;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
box-sizing: border-box;
|
||||
transition: border-color .15s, opacity .15s;
|
||||
opacity: .7;
|
||||
}
|
||||
.diary-detail-thumb:hover { opacity: 1; }
|
||||
.diary-detail-thumb--active {
|
||||
border-color: var(--c-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.diary-detail-thumb { width: 72px; height: 72px; }
|
||||
}
|
||||
|
||||
/* Detail-View: Foto-Galerie horizontal */
|
||||
.diary-detail-gallery {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x mandatory;
|
||||
margin: 0 -20px 20px;
|
||||
padding: 0 20px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.diary-detail-gallery::-webkit-scrollbar { display: none; }
|
||||
.diary-detail-gallery-item {
|
||||
flex: 0 0 auto;
|
||||
width: min(75vw, 280px);
|
||||
height: 200px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
scroll-snap-align: start;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.diary-detail-gallery-item img,
|
||||
.diary-detail-gallery-item video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Leaflet-Attribution ausblenden */
|
||||
.leaflet-control-attribution { display: none !important; }
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
--c-warning: #D4923A;
|
||||
--c-warning-subtle: #FDF3E3;
|
||||
--c-amber: #E4A020; /* Goldgelb — "Heute"-Akzent, distinct von Primary */
|
||||
--c-icon: #7A6A58; /* Standard-Icon-Farbe (= text-secondary im Light-Mode) */
|
||||
--c-info: #4A7A9B;
|
||||
--c-info-subtle: #E8F2F8;
|
||||
|
||||
|
|
@ -137,8 +138,11 @@
|
|||
|
||||
--c-text: #F0EAE0;
|
||||
--c-text-secondary: #C0B0A0;
|
||||
--c-text-muted: #806A58;
|
||||
--c-text-muted: #9A8878;
|
||||
--c-text-inverse: #2A1F14;
|
||||
--c-icon: #B0A090;
|
||||
--c-amber: #C48820;
|
||||
--c-success: #6A9E58;
|
||||
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30);
|
||||
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@
|
|||
justify-content: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
color: var(--c-text-muted);
|
||||
color: var(--c-icon, var(--c-text-secondary));
|
||||
transition: color var(--transition-fast);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
|
|
|
|||
|
|
@ -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="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="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="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>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 41 KiB |
|
|
@ -88,9 +88,9 @@
|
|||
</script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=237">
|
||||
<link rel="stylesheet" href="/css/components.css?v=232">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=382">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=382">
|
||||
<link rel="stylesheet" href="/css/components.css?v=382">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -124,6 +124,9 @@
|
|||
<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
|
||||
</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>
|
||||
<div class="sidebar-item" data-page="map">
|
||||
|
|
@ -371,6 +374,10 @@
|
|||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-notes">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- MOBILE BOTTOM NAVIGATION -->
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ const API = (() => {
|
|||
const q = new URLSearchParams(params).toString();
|
||||
return get(`/dogs/${dogId}/diary${q ? '?' + q : ''}`);
|
||||
},
|
||||
stats(dogId) { return get(`/dogs/${dogId}/diary/stats`); },
|
||||
get(dogId, entryId) { return get(`/dogs/${dogId}/diary/${entryId}`); },
|
||||
create(dogId, data) { return post(`/dogs/${dogId}/diary`, data); },
|
||||
update(dogId, id, data){ return patch(`/dogs/${dogId}/diary/${id}`, data); },
|
||||
|
|
@ -137,6 +138,8 @@ const API = (() => {
|
|||
nearby(dogId, lat, 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
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -576,7 +603,7 @@ const API = (() => {
|
|||
get, post, put, patch, del, upload,
|
||||
auth, dogs, diary, health, tieraerzte, poison,
|
||||
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,
|
||||
APIError,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
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 = (() => {
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -28,6 +28,10 @@ window.Page_dog_profile = (() => {
|
|||
if (e.target.closest('#profile-goto-login')) {
|
||||
App.navigate('settings');
|
||||
}
|
||||
if (e.target.closest('[data-action="goto-weight"]')) {
|
||||
App.navigate('health', true, { tab: 'gewicht', openForm: true });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
await _render();
|
||||
|
|
@ -119,7 +123,7 @@ window.Page_dog_profile = (() => {
|
|||
</div>
|
||||
` : ''}
|
||||
${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);
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</tr></thead>
|
||||
<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><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>
|
||||
|
|
@ -239,6 +239,7 @@ window.Page_erste_hilfe = (() => {
|
|||
`;
|
||||
_bindTabs();
|
||||
_bindAccordions();
|
||||
_bindNoteButtons();
|
||||
_activateTab('lebensgefahr');
|
||||
}
|
||||
|
||||
|
|
@ -340,6 +341,10 @@ window.Page_erste_hilfe = (() => {
|
|||
${massnahmenHtml}
|
||||
${warnHtml}
|
||||
${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,'"')}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||
</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : '';
|
||||
|
||||
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
|
||||
// ----------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -226,7 +226,14 @@ window.Page_events = (() => {
|
|||
</a>
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -268,7 +275,7 @@ window.Page_events = (() => {
|
|||
const popup = `
|
||||
<div style="min-width:180px">
|
||||
<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.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})"
|
||||
|
|
@ -634,11 +641,77 @@ window.Page_events = (() => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Notiz-Button
|
||||
const noteBtn = e.target.closest('.ev-note-btn');
|
||||
if (noteBtn) {
|
||||
e.stopPropagation();
|
||||
_openNoteModal(
|
||||
'event',
|
||||
parseInt(noteBtn.dataset.evNoteId),
|
||||
noteBtn.dataset.evNoteLabel,
|
||||
noteBtn.dataset.evNoteOrt || null
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Karten-Klick → Detail
|
||||
const card = e.target.closest('[data-ev-id]');
|
||||
if (card) { _showDetail(parseInt(card.dataset.evId)); }
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
|
||||
// ----------------------------------------------------------
|
||||
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
|
||||
let existingNote = null;
|
||||
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
|
||||
|
||||
const ovl = document.createElement('div');
|
||||
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
|
||||
ovl.innerHTML = `
|
||||
<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 };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ window.Page_friends = (() => {
|
|||
<div style="width:36px;height:36px;border-radius:50%;flex-shrink:0;
|
||||
background:var(--c-primary-subtle);
|
||||
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>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,10 +34,17 @@ window.Page_health = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// LIFECYCLE
|
||||
// ----------------------------------------------------------
|
||||
async function init(container, appState) {
|
||||
async function init(container, appState, params) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
if (params?.tab) {
|
||||
const valid = _getTabs().some(t => t.key === params.tab);
|
||||
if (valid) _activeTab = params.tab;
|
||||
}
|
||||
await _render();
|
||||
if (params?.openForm) {
|
||||
setTimeout(() => _showForm(null, _activeTab), 200);
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
|
|
@ -400,6 +407,10 @@ window.Page_health = (() => {
|
|||
Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
|
||||
</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>
|
||||
`;
|
||||
|
|
@ -445,6 +456,10 @@ window.Page_health = (() => {
|
|||
</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>` : ''}
|
||||
<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>
|
||||
`;
|
||||
|
|
@ -493,6 +508,10 @@ window.Page_health = (() => {
|
|||
</span>
|
||||
</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>
|
||||
`).join('');
|
||||
|
||||
|
|
@ -726,6 +745,10 @@ window.Page_health = (() => {
|
|||
${interval ? ` · Abstand zur Vorherigen: ${interval} Tage` : ''}
|
||||
</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>`;
|
||||
}).join('');
|
||||
|
|
@ -760,6 +783,10 @@ window.Page_health = (() => {
|
|||
${e.bis_datum ? ` · bis ${UI.time.format(e.bis_datum + 'T00:00:00')}` : ''}
|
||||
</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>
|
||||
`).join('')}
|
||||
|
|
@ -797,6 +824,10 @@ window.Page_health = (() => {
|
|||
</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>` : ''}
|
||||
<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>
|
||||
`).join('');
|
||||
|
|
@ -837,6 +868,10 @@ window.Page_health = (() => {
|
|||
${count > 1 ? ` · ${count} Dateien` : ''}
|
||||
</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
|
||||
? `<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'
|
||||
|
|
@ -874,6 +909,14 @@ window.Page_health = (() => {
|
|||
const entry = (_data[_activeTab] || []).find(e => e.id === id);
|
||||
if (entry) card.addEventListener('click', () => _openDetail(entry));
|
||||
});
|
||||
content.querySelectorAll('[data-action="open-note"]').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const id = parseInt(btn.dataset.entryId);
|
||||
const label = btn.dataset.label || '';
|
||||
_openNoteModal('health', id, label, null);
|
||||
});
|
||||
});
|
||||
// Praxis öffnen
|
||||
content.querySelectorAll('[data-action="open-praxis"]').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
|
|
@ -1166,6 +1209,9 @@ window.Page_health = (() => {
|
|||
if (!_data[t]) _data[t] = [];
|
||||
_data[t].unshift(saved);
|
||||
UI.toast.success('Eintrag erstellt.');
|
||||
if (t === 'gewicht' && saved.wert) {
|
||||
_appState.activeDog.gewicht_kg = saved.wert;
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-File-Upload
|
||||
|
|
@ -1830,6 +1876,89 @@ window.Page_health = (() => {
|
|||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
|
||||
// ----------------------------------------------------------
|
||||
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
|
||||
// Vorhandenes Modal entfernen falls noch offen
|
||||
document.getElementById('by-note-modal')?.remove();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'by-note-modal';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
|
||||
|
||||
overlay.innerHTML = `
|
||||
<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 };
|
||||
|
||||
})();
|
||||
|
|
|
|||
693
backend/static/js/pages/notes.js
Normal file
693
backend/static/js/pages/notes.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _formatTime(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
try {
|
||||
const d = new Date(isoStr.replace(' ', 'T') + (isoStr.includes('T') || isoStr.endsWith('Z') ? '' : 'Z'));
|
||||
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function _dateGroup(isoStr) {
|
||||
if (!isoStr) return 'Älteres';
|
||||
try {
|
||||
const d = new Date(isoStr.replace(' ', 'T') + (isoStr.includes('T') || isoStr.endsWith('Z') ? '' : 'Z'));
|
||||
const now = new Date();
|
||||
const diffDays = (now - d) / 86400000;
|
||||
if (diffDays < 1 && d.getDate() === now.getDate()) return 'Heute';
|
||||
if (diffDays < 7) return 'Diese Woche';
|
||||
return 'Älteres';
|
||||
} catch (_) { return 'Älteres'; }
|
||||
}
|
||||
|
||||
function _truncate(str, max = 150) {
|
||||
if (!str) return '';
|
||||
return str.length > max ? str.slice(0, max) + '…' : str;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Daten laden
|
||||
// ----------------------------------------------------------
|
||||
async function _load() {
|
||||
const params = {};
|
||||
if (_filterType) params.parent_type = _filterType;
|
||||
if (_sortMode !== 'newest') params.sort = _sortMode;
|
||||
if (_searchQ) params.q = _searchQ;
|
||||
return await API.notes.getAll(params);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Filter/Sortierung anwenden (client-seitig falls API alles zurückgibt)
|
||||
// ----------------------------------------------------------
|
||||
function _applySort(list) {
|
||||
const copy = [...list];
|
||||
if (_sortMode === 'newest') {
|
||||
copy.sort((a, b) => new Date(b.updated_at || b.created_at) - new Date(a.updated_at || a.created_at));
|
||||
} else if (_sortMode === 'type') {
|
||||
copy.sort((a, b) => (a.parent_type || '').localeCompare(b.parent_type || '', 'de'));
|
||||
} else if (_sortMode === 'location') {
|
||||
copy.sort((a, b) => (a.location_name || '').localeCompare(b.location_name || '', 'de'));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Rendern
|
||||
// ----------------------------------------------------------
|
||||
function _render() {
|
||||
const kiEnabled = _appState?.user?.notes_ki_enabled !== 0;
|
||||
const sorted = _applySort(_notes);
|
||||
|
||||
// Gruppen aufbauen
|
||||
const groups = { 'Heute': [], 'Diese Woche': [], 'Älteres': [] };
|
||||
sorted.forEach(n => {
|
||||
const g = _dateGroup(n.updated_at || n.created_at);
|
||||
groups[g].push(n);
|
||||
});
|
||||
|
||||
const groupHtml = Object.entries(groups)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([label, items]) => `
|
||||
<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 };
|
||||
|
||||
})();
|
||||
|
|
@ -126,7 +126,7 @@ window.Page_onboarding = (() => {
|
|||
<div style="width:36px;height:36px;border-radius:var(--radius-md);
|
||||
background:var(--c-primary-subtle);flex-shrink:0;
|
||||
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>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
@ -167,7 +167,7 @@ window.Page_onboarding = (() => {
|
|||
<div style="width:64px;height:64px;border-radius:50%;
|
||||
background:var(--c-primary-subtle);margin:0 auto var(--space-4);
|
||||
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>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
@ -262,7 +262,7 @@ window.Page_onboarding = (() => {
|
|||
<div style="width:80px;height:80px;border-radius:50%;
|
||||
background:var(--c-success-subtle,#dcfce7);margin:0 auto var(--space-4);
|
||||
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>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1805,6 +1805,7 @@ window.Page_routes = (() => {
|
|||
${_actionBtn('rd-gpx', 'download-simple', 'GPX')}
|
||||
${_actionBtn('rd-share', 'arrow-square-out', 'Teilen')}
|
||||
${_actionBtn('rd-navi', 'map-pin', 'Navi')}
|
||||
${_appState.user ? _actionBtn('rd-note', 'note-pencil', 'Notiz') : ''}
|
||||
</div>
|
||||
${ownerRow}
|
||||
<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); }
|
||||
});
|
||||
|
||||
// Notiz-Button
|
||||
document.getElementById('rd-note')?.addEventListener('click', () => {
|
||||
const label = route.name || (route.distanz_km ? route.distanz_km.toFixed(1) + ' km' : 'Route');
|
||||
_openNoteModal('route', route.id, label, null);
|
||||
});
|
||||
|
||||
// Mini-Map
|
||||
let _detailMap = null;
|
||||
setTimeout(() => {
|
||||
|
|
@ -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 };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -266,6 +266,30 @@ window.Page_settings = (() => {
|
|||
</select>
|
||||
</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>
|
||||
|
||||
|
|
@ -635,6 +659,25 @@ window.Page_settings = (() => {
|
|||
: 'Pocket-Modus deaktiviert.');
|
||||
});
|
||||
|
||||
document.getElementById('toggle-notes-ki')?.addEventListener('change', async e => {
|
||||
const enabled = e.target.checked;
|
||||
const track = document.getElementById('toggle-notes-ki-track');
|
||||
const thumb = document.getElementById('toggle-notes-ki-thumb');
|
||||
if (track) track.style.background = enabled ? 'var(--c-primary)' : 'var(--c-border)';
|
||||
if (thumb) thumb.style.left = enabled ? '22px' : '2px';
|
||||
try {
|
||||
await API.patch('/profile', { notes_ki_enabled: enabled ? 1 : 0 });
|
||||
_appState.user.notes_ki_enabled = enabled ? 1 : 0;
|
||||
UI.toast.success(enabled ? 'KI-Notiz-Assistent aktiviert.' : 'KI-Notiz-Assistent deaktiviert.');
|
||||
} catch (err) {
|
||||
UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.');
|
||||
// Revert UI
|
||||
e.target.checked = !enabled;
|
||||
if (track) track.style.background = !enabled ? 'var(--c-primary)' : 'var(--c-border)';
|
||||
if (thumb) thumb.style.left = !enabled ? '22px' : '2px';
|
||||
}
|
||||
});
|
||||
|
||||
_loadReferral();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,12 @@ window.Page_sitting = (() => {
|
|||
<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-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>
|
||||
`;
|
||||
|
|
@ -704,6 +710,19 @@ window.Page_sitting = (() => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Notiz-Button auf Sitter-Karte
|
||||
const noteBtn = e.target.closest('.sit-note-btn');
|
||||
if (noteBtn) {
|
||||
e.stopPropagation();
|
||||
_openNoteModal(
|
||||
'sitting',
|
||||
parseInt(noteBtn.dataset.sitNoteId),
|
||||
noteBtn.dataset.sitNoteLabel,
|
||||
null
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sitter-Karte
|
||||
const sitterCard = e.target.closest('[data-sit-id]');
|
||||
if (sitterCard && !e.target.closest('button')) {
|
||||
|
|
@ -741,6 +760,59 @@ window.Page_sitting = (() => {
|
|||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
|
||||
// ----------------------------------------------------------
|
||||
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
|
||||
let existingNote = null;
|
||||
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
|
||||
|
||||
const ovl = document.createElement('div');
|
||||
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
|
||||
ovl.innerHTML = `
|
||||
<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 };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -895,6 +895,7 @@ window.Page_uebungen = (() => {
|
|||
_bindAccordions();
|
||||
_bindStatusButtons();
|
||||
_bindLogButtons();
|
||||
_bindNotizButtons();
|
||||
if (_activeTab === 'ki-trainer') _loadKiTrainerFeedback();
|
||||
}
|
||||
|
||||
|
|
@ -965,6 +966,19 @@ window.Page_uebungen = (() => {
|
|||
Einheit
|
||||
</button>
|
||||
${_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"
|
||||
data-tab="${_esc(_activeTab)}"
|
||||
data-name="${_esc(u.name)}"
|
||||
|
|
@ -1006,7 +1020,7 @@ window.Page_uebungen = (() => {
|
|||
background:#78350f22;border:1px solid #d9770644;border-radius:var(--radius-sm);
|
||||
font-size:var(--text-xs);color:var(--c-text);line-height:1.4;
|
||||
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>
|
||||
</div>
|
||||
` : ''}
|
||||
|
|
@ -1039,7 +1053,7 @@ window.Page_uebungen = (() => {
|
|||
${u.fehler.length ? `
|
||||
<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">
|
||||
<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
|
||||
</p>
|
||||
<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) {
|
||||
// Build the modal HTML
|
||||
const modalId = 'ueb-log-modal';
|
||||
|
|
|
|||
|
|
@ -192,6 +192,18 @@ window.Page_walks = (() => {
|
|||
el.querySelectorAll('.walks-card').forEach(card => {
|
||||
card.addEventListener('click', () => _openDetail(parseInt(card.dataset.id)));
|
||||
});
|
||||
|
||||
el.querySelectorAll('.wk-note-btn').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
_openNoteModal(
|
||||
'walk',
|
||||
parseInt(btn.dataset.wkNoteId),
|
||||
btn.dataset.wkNoteLabel,
|
||||
btn.dataset.wkNoteOrt || null
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _walkCardHTML(w) {
|
||||
|
|
@ -217,7 +229,16 @@ window.Page_walks = (() => {
|
|||
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
|
||||
</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>`;
|
||||
}
|
||||
|
||||
|
|
@ -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 };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ window.Page_welcome = (() => {
|
|||
style="width:36px;height:36px;border-radius:var(--radius-md);
|
||||
background:var(--c-primary);flex-shrink:0;
|
||||
display:flex;align-items:center;justify-content:center">
|
||||
<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>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
@ -237,7 +237,7 @@ window.Page_welcome = (() => {
|
|||
<div style="width:34px;height:34px;border-radius:var(--radius-md);
|
||||
background:var(--c-primary-subtle);flex-shrink:0;
|
||||
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>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,15 +3,15 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v370';
|
||||
const CACHE_VERSION = 'by-v405';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
||||
// index.html wird NICHT pre-gecacht (immer Network-First)
|
||||
const STATIC_ASSETS = [
|
||||
'/css/design-system.css',
|
||||
'/css/layout.css',
|
||||
'/css/components.css',
|
||||
'/css/design-system.css?v=382',
|
||||
'/css/layout.css?v=382',
|
||||
'/css/components.css?v=382',
|
||||
'/icons/phosphor.svg',
|
||||
'/js/api.js',
|
||||
'/js/ui.js',
|
||||
|
|
@ -82,8 +82,8 @@ self.addEventListener('fetch', event => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Seiten-Module (/js/pages/…): immer Network-First (versioniert über ?v=, kein alter Cache-Treffer)
|
||||
if (url.pathname.startsWith('/js/pages/')) {
|
||||
// CSS + Seiten-Module: immer Network-First — damit iOS nie veraltete CSS cached
|
||||
if (url.pathname.startsWith('/css/') || url.pathname.startsWith('/js/pages/')) {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then(response => {
|
||||
|
|
|
|||
410
promotion/banyaro_hundeschulen.html
Normal file
410
promotion/banyaro_hundeschulen.html
Normal 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 & 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. v. m.</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-name">Trainings-Tagebuch</div>
|
||||
<div class="feature-desc">Halter dokumentieren Fortschritt & Stimmung</div>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<div class="feature-name">Rassen-Wiki</div>
|
||||
<div class="feature-desc">Über 900 Rassen mit Charakter- & 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 & 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 & 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> · Entwickler Banyaro
|
||||
</div>
|
||||
<div class="footer-contact">
|
||||
<strong>kontakt@banyaro.app</strong> · banyaro.app
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
407
promotion/banyaro_tieraerzte.html
Normal file
407
promotion/banyaro_tieraerzte.html
Normal 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 & 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 & 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 & 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> · Entwickler Banyaro
|
||||
</div>
|
||||
<div class="footer-contact">
|
||||
<strong>kontakt@banyaro.app</strong> · banyaro.app
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue