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)
|
## Implementierungsstand (aktuell: 2026-04-25, SW by-v370, APP_VER 355)
|
||||||
|
|
||||||
### Sprint 11 (2026-04-25) ✅
|
### Sprint 11 (2026-04-25) ✅
|
||||||
|
|
@ -222,7 +266,7 @@ Maps: Leaflet.js + OpenStreetMap (kostenlos, kein Google-Lock)
|
||||||
#### 1.2 Gesundheit & Impfpass
|
#### 1.2 Gesundheit & Impfpass
|
||||||
- [ ] Impfungen, Entwurmungen, Tierarztbesuche digital
|
- [ ] Impfungen, Entwurmungen, Tierarztbesuche digital
|
||||||
- [ ] Medikamenten-Reminder (Push Notification)
|
- [ ] Medikamenten-Reminder (Push Notification)
|
||||||
- [ ] Gewichtsverlauf-Chart
|
- [x] Gewichtsverlauf-Chart ✅
|
||||||
- [ ] Einfacher Symptom-Checker (KI-gestützt, Triage: beobachten/Tierarzt/Notfall)
|
- [ ] Einfacher Symptom-Checker (KI-gestützt, Triage: beobachten/Tierarzt/Notfall)
|
||||||
|
|
||||||
#### 1.3 Giftköder-Alarm
|
#### 1.3 Giftköder-Alarm
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ def get_current_user(
|
||||||
user_id = int(payload["sub"])
|
user_id = int(payload["sub"])
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason FROM users WHERE id=?",
|
"SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled FROM users WHERE id=?",
|
||||||
(user_id,)
|
(user_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -534,6 +534,13 @@ def _migrate(conn_factory):
|
||||||
("pflege_tipps", "fell_pflege_art", "TEXT"),
|
("pflege_tipps", "fell_pflege_art", "TEXT"),
|
||||||
# Wiki-Foto-Einreichungen: Bildrechte-Bestätigung
|
# Wiki-Foto-Einreichungen: Bildrechte-Bestätigung
|
||||||
("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"),
|
("wiki_foto_submissions", "rights_confirmed", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
# Tagebuch: Wetter + POI-Metadaten beim Eintrag
|
||||||
|
("diary", "weather_json", "TEXT"),
|
||||||
|
("diary", "poi_json", "TEXT"),
|
||||||
|
# Notizen: Ort + Label + KI-Assistent User-Setting
|
||||||
|
("notes", "location_name", "TEXT"),
|
||||||
|
("notes", "parent_label", "TEXT"),
|
||||||
|
("users", "notes_ki_enabled", "INTEGER NOT NULL DEFAULT 1"),
|
||||||
]
|
]
|
||||||
with conn_factory() as conn:
|
with conn_factory() as conn:
|
||||||
for table, column, col_type in migrations:
|
for table, column, col_type in migrations:
|
||||||
|
|
@ -1131,3 +1138,22 @@ def _migrate(conn_factory):
|
||||||
CREATE INDEX IF NOT EXISTS idx_ki_daily_source ON ki_daily_calls(date, source);
|
CREATE INDEX IF NOT EXISTS idx_ki_daily_source ON ki_daily_calls(date, source);
|
||||||
""")
|
""")
|
||||||
logger.info("Migration: ki_daily_calls.source bereit.")
|
logger.info("Migration: ki_daily_calls.source bereit.")
|
||||||
|
|
||||||
|
# Notizen: generische polymorphe Notiz-Tabelle
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
parent_type TEXT NOT NULL,
|
||||||
|
parent_id INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL DEFAULT '',
|
||||||
|
meta_json TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_parent
|
||||||
|
ON notes(parent_type, parent_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_user
|
||||||
|
ON notes(user_id, created_at DESC);
|
||||||
|
""")
|
||||||
|
logger.info("Migration: notes Tabelle bereit.")
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,24 @@ class _UploadSizeMiddleware(BaseHTTPMiddleware):
|
||||||
app.add_middleware(_UploadSizeMiddleware)
|
app.add_middleware(_UploadSizeMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
class _CacheControlMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Setzt Cache-Control-Header für statische Assets.
|
||||||
|
CSS/JS: no-cache (ETag-Validierung) — iOS cached sonst ewig ohne Ablaufdatum.
|
||||||
|
Versioned Assets (?v=…): immutable — URL ändert sich bei Updates.
|
||||||
|
"""
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
path = request.url.path
|
||||||
|
if path.startswith(("/css/", "/js/", "/icons/phosphor.svg")):
|
||||||
|
if "v=" in str(request.url.query):
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||||
|
else:
|
||||||
|
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||||
|
return response
|
||||||
|
|
||||||
|
app.add_middleware(_CacheControlMiddleware)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# API-Router registrieren (werden nach und nach hinzugefügt)
|
# API-Router registrieren (werden nach und nach hinzugefügt)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -122,6 +140,7 @@ from routes.praise import router as praise_router
|
||||||
from routes.weather import router as weather_router
|
from routes.weather import router as weather_router
|
||||||
from routes.social import router as social_router
|
from routes.social import router as social_router
|
||||||
from routes.moderation import router as moderation_router
|
from routes.moderation import router as moderation_router
|
||||||
|
from routes.notes import router as notes_router
|
||||||
|
|
||||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||||
|
|
@ -163,6 +182,7 @@ app.include_router(achievements_router, prefix="/api/achievements", tags=
|
||||||
app.include_router(training_router, prefix="/api/training", tags=["Training"])
|
app.include_router(training_router, prefix="/api/training", tags=["Training"])
|
||||||
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
|
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
|
||||||
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
|
app.include_router(moderation_router, prefix="/api/moderation", tags=["Moderation"])
|
||||||
|
app.include_router(notes_router, prefix="/api/notes", tags=["Notes"])
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,36 @@ def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def extract_gps_from_exif(data: bytes) -> tuple | None:
|
||||||
|
"""EXIF-GPS aus Bilddaten lesen. Gibt (lat, lon) zurück oder None."""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.open(io.BytesIO(data))
|
||||||
|
exif = img._getexif()
|
||||||
|
if not exif:
|
||||||
|
return None
|
||||||
|
gps = exif.get(34853) # GPSInfo tag
|
||||||
|
if not gps:
|
||||||
|
return None
|
||||||
|
lat_dms = gps.get(2)
|
||||||
|
lon_dms = gps.get(4)
|
||||||
|
lat_ref = gps.get(1, 'N')
|
||||||
|
lon_ref = gps.get(3, 'E')
|
||||||
|
if not lat_dms or not lon_dms:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def dms(v):
|
||||||
|
return float(v[0]) + float(v[1]) / 60 + float(v[2]) / 3600
|
||||||
|
|
||||||
|
lat = dms(lat_dms) * (-1 if lat_ref == 'S' else 1)
|
||||||
|
lon = dms(lon_dms) * (-1 if lon_ref == 'W' else 1)
|
||||||
|
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
|
||||||
|
return None
|
||||||
|
return round(lat, 6), round(lon, 6)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def convert_media(data: bytes, filename: str) -> Tuple[bytes, str]:
|
def convert_media(data: bytes, filename: str) -> Tuple[bytes, str]:
|
||||||
"""Convert HEIC→JPEG and MOV/AVI/M4V→MP4; pass everything else through."""
|
"""Convert HEIC→JPEG and MOV/AVI/M4V→MP4; pass everything else through."""
|
||||||
ext = os.path.splitext(filename or "")[1].lower()
|
ext = os.path.splitext(filename or "")[1].lower()
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
"""BAN YARO — Tagebuch Routes"""
|
"""BAN YARO — Tagebuch Routes"""
|
||||||
|
|
||||||
import os, uuid, json, math
|
import os, uuid, json, math, logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user
|
from auth import get_current_user, require_admin
|
||||||
import ki as KI
|
import ki as KI
|
||||||
import httpx
|
import httpx
|
||||||
from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload
|
import weather as weather_mod
|
||||||
|
from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload, extract_gps_from_exif
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
|
|
@ -142,6 +145,69 @@ def _entry_dict(row, dog_ids_map: dict, media_map: dict = None) -> dict:
|
||||||
return e
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{dog_id}/diary/stats")
|
||||||
|
async def diary_stats(dog_id: int, user=Depends(get_current_user)):
|
||||||
|
"""Gesamtstatistik für das Tagebuch (unabhängig von Pagination)."""
|
||||||
|
with db() as conn:
|
||||||
|
_can_read_dog(dog_id, user["id"], conn)
|
||||||
|
total = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
|
||||||
|
"WHERE (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
|
||||||
|
).fetchone()[0]
|
||||||
|
photos = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM diary_media dm "
|
||||||
|
"JOIN diary d ON d.id=dm.diary_id LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
|
||||||
|
"WHERE (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
|
||||||
|
).fetchone()[0]
|
||||||
|
days = conn.execute(
|
||||||
|
"SELECT COUNT(DISTINCT d.datum) FROM diary d LEFT JOIN diary_dogs dd ON dd.diary_id=d.id "
|
||||||
|
"WHERE d.datum IS NOT NULL AND (d.dog_id=? OR dd.dog_id=?)", (dog_id, dog_id)
|
||||||
|
).fetchone()[0]
|
||||||
|
return {"entries": total, "photos": photos, "days": days}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{dog_id}/diary/calendar")
|
||||||
|
async def diary_calendar(dog_id: int, user=Depends(get_current_user)):
|
||||||
|
"""Alle Einträge minimal für Kalenderansicht: id, datum, cover_url."""
|
||||||
|
with db() as conn:
|
||||||
|
_can_read_dog(dog_id, user["id"], conn)
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT DISTINCT d.id, d.datum,
|
||||||
|
(SELECT dm.url FROM diary_media dm
|
||||||
|
WHERE dm.diary_id=d.id AND dm.media_type='image'
|
||||||
|
ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url
|
||||||
|
FROM diary d
|
||||||
|
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
|
||||||
|
WHERE (d.dog_id=? OR dd.dog_id=?)
|
||||||
|
AND d.datum IS NOT NULL
|
||||||
|
ORDER BY d.datum DESC""",
|
||||||
|
(dog_id, dog_id)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{dog_id}/diary/locations")
|
||||||
|
async def diary_locations(dog_id: int, user=Depends(get_current_user)):
|
||||||
|
"""Alle Tagebucheinträge mit GPS — minimal für Karten-Ansicht."""
|
||||||
|
with db() as conn:
|
||||||
|
_can_read_dog(dog_id, user["id"], conn)
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT DISTINCT d.id, d.datum, d.titel, d.gps_lat, d.gps_lon,
|
||||||
|
d.location_name, d.weather_json,
|
||||||
|
(SELECT dm.url FROM diary_media dm
|
||||||
|
WHERE dm.diary_id=d.id AND dm.media_type='image'
|
||||||
|
ORDER BY dm.is_cover DESC, dm.sort_order LIMIT 1) AS cover_url,
|
||||||
|
(SELECT COUNT(*) FROM diary_media dm WHERE dm.diary_id=d.id) AS media_count
|
||||||
|
FROM diary d
|
||||||
|
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
|
||||||
|
WHERE (d.dog_id=? OR dd.dog_id=?)
|
||||||
|
AND d.gps_lat IS NOT NULL AND d.gps_lon IS NOT NULL
|
||||||
|
ORDER BY d.datum DESC""",
|
||||||
|
(dog_id, dog_id)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{dog_id}/diary")
|
@router.get("/{dog_id}/diary")
|
||||||
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
|
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
|
||||||
q: Optional[str] = None, milestone: int = 0,
|
q: Optional[str] = None, milestone: int = 0,
|
||||||
|
|
@ -226,10 +292,95 @@ async def create_diary(dog_id: int, data: DiaryCreate,
|
||||||
_set_dog_ids(conn, entry["id"], all_dogs)
|
_set_dog_ids(conn, entry["id"], all_dogs)
|
||||||
dogs_map = _fetch_dog_ids(conn, [entry["id"]])
|
dogs_map = _fetch_dog_ids(conn, [entry["id"]])
|
||||||
media_map = _fetch_media_items(conn, [entry["id"]])
|
media_map = _fetch_media_items(conn, [entry["id"]])
|
||||||
|
entry_id = entry["id"]
|
||||||
|
|
||||||
|
# Wetter + POIs asynchron nach dem DB-Commit holen (außerhalb des with-Blocks)
|
||||||
|
if data.gps_lat is not None and data.gps_lon is not None:
|
||||||
|
weather_json = None
|
||||||
|
poi_json = None
|
||||||
|
|
||||||
|
# Wetter holen
|
||||||
|
try:
|
||||||
|
wd = await weather_mod.get_weather_for_location(data.gps_lat, data.gps_lon)
|
||||||
|
weather_json = json.dumps(wd)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Wetter-Abfrage beim Diary-Create fehlgeschlagen: %s", exc)
|
||||||
|
|
||||||
|
# POIs holen
|
||||||
|
try:
|
||||||
|
pois = await _fetch_pois_for_coords(data.gps_lat, data.gps_lon, limit=5)
|
||||||
|
if pois:
|
||||||
|
poi_json = json.dumps(pois)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("POI-Abfrage beim Diary-Create fehlgeschlagen: %s", exc)
|
||||||
|
|
||||||
|
# In DB speichern und Entry aktualisieren
|
||||||
|
if weather_json is not None or poi_json is not None:
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE diary SET weather_json=?, poi_json=? WHERE id=?",
|
||||||
|
(weather_json, poi_json, entry_id)
|
||||||
|
)
|
||||||
|
entry = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
|
||||||
|
|
||||||
return _entry_dict(entry, dogs_map, media_map)
|
return _entry_dict(entry, dogs_map, media_map)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_pois_for_coords(lat: float, lon: float, limit: int = 5) -> list:
|
||||||
|
"""Holt POIs für Koordinaten via Overpass (analog zu nearby_places, aber ohne DB/Auth)."""
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=6) as client:
|
||||||
|
def _overpass_q(radius):
|
||||||
|
return (
|
||||||
|
f'[out:json][timeout:6];'
|
||||||
|
f'('
|
||||||
|
f' node["name"]["tourism"](around:{radius},{lat},{lon});'
|
||||||
|
f' node["name"]["historic"](around:{radius},{lat},{lon});'
|
||||||
|
f' node["name"]["leisure"](around:{radius},{lat},{lon});'
|
||||||
|
f' node["name"]["amenity"](around:{radius},{lat},{lon});'
|
||||||
|
f' node["name"]["shop"](around:{radius},{lat},{lon});'
|
||||||
|
f' way["name"]["tourism"](around:{radius},{lat},{lon});'
|
||||||
|
f' way["name"]["historic"](around:{radius},{lat},{lon});'
|
||||||
|
f' way["name"]["leisure"](around:{radius},{lat},{lon});'
|
||||||
|
f');'
|
||||||
|
f'out center;'
|
||||||
|
)
|
||||||
|
ov = await client.post(
|
||||||
|
"https://overpass-api.de/api/interpreter",
|
||||||
|
data={"data": _overpass_q(800)},
|
||||||
|
headers={"User-Agent": "BanYaro/1.0"},
|
||||||
|
)
|
||||||
|
elements = ov.json().get("elements", []) if ov.status_code == 200 else []
|
||||||
|
if not elements:
|
||||||
|
ov2 = await client.post(
|
||||||
|
"https://overpass-api.de/api/interpreter",
|
||||||
|
data={"data": _overpass_q(2000)},
|
||||||
|
headers={"User-Agent": "BanYaro/1.0"},
|
||||||
|
)
|
||||||
|
elements = ov2.json().get("elements", []) if ov2.status_code == 200 else []
|
||||||
|
seen = set()
|
||||||
|
for el in elements:
|
||||||
|
n = el.get("tags", {}).get("name")
|
||||||
|
if not n or n.lower() in seen:
|
||||||
|
continue
|
||||||
|
seen.add(n.lower())
|
||||||
|
elat = el.get("lat") or el.get("center", {}).get("lat")
|
||||||
|
elon = el.get("lon") or el.get("center", {}).get("lon")
|
||||||
|
if elat and elon:
|
||||||
|
km = _haversine_km(lat, lon, elat, elon)
|
||||||
|
typ = next((el["tags"].get(k) for k in
|
||||||
|
["tourism", "historic", "leisure", "amenity", "shop"]
|
||||||
|
if el["tags"].get(k)), "place")
|
||||||
|
results.append({"name": n, "type": typ,
|
||||||
|
"distance_m": int(km * 1000)})
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("_fetch_pois_for_coords Fehler: %s", exc)
|
||||||
|
return results[:limit]
|
||||||
|
|
||||||
|
|
||||||
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
||||||
R = 6371
|
R = 6371
|
||||||
dlat = math.radians(lat2 - lat1)
|
dlat = math.radians(lat2 - lat1)
|
||||||
|
|
@ -508,6 +659,11 @@ async def upload_media(dog_id: int, entry_id: int,
|
||||||
|
|
||||||
media_url = f"/media/diary/{filename}"
|
media_url = f"/media/diary/{filename}"
|
||||||
|
|
||||||
|
# EXIF-GPS aus Bild extrahieren (nur bei Bilddateien)
|
||||||
|
exif_gps = None
|
||||||
|
if media_type == "image":
|
||||||
|
exif_gps = extract_gps_from_exif(raw_data)
|
||||||
|
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
# sort_order = nächste freie Position
|
# sort_order = nächste freie Position
|
||||||
max_order = conn.execute(
|
max_order = conn.execute(
|
||||||
|
|
@ -525,8 +681,38 @@ async def upload_media(dog_id: int, entry_id: int,
|
||||||
(entry_id,)
|
(entry_id,)
|
||||||
).fetchone()["id"]
|
).fetchone()["id"]
|
||||||
|
|
||||||
return {"id": new_id, "url": media_url, "media_type": media_type,
|
# GPS aus EXIF in den Eintrag schreiben, wenn noch keine Koordinaten vorhanden
|
||||||
|
gps_written = False
|
||||||
|
if exif_gps:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT gps_lat FROM diary WHERE id=?", (entry_id,)
|
||||||
|
).fetchone()
|
||||||
|
if existing and existing["gps_lat"] is None:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE diary SET gps_lat=?, gps_lon=? WHERE id=?",
|
||||||
|
(exif_gps[0], exif_gps[1], entry_id)
|
||||||
|
)
|
||||||
|
gps_written = True
|
||||||
|
|
||||||
|
# Wetter + POI nachladen wenn GPS frisch gesetzt
|
||||||
|
if gps_written and exif_gps:
|
||||||
|
try:
|
||||||
|
wd = await weather_mod.get_weather_for_location(exif_gps[0], exif_gps[1])
|
||||||
|
pois = await _fetch_pois_for_coords(exif_gps[0], exif_gps[1], limit=5)
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE diary SET weather_json=COALESCE(weather_json,?), poi_json=COALESCE(poi_json,?) WHERE id=?",
|
||||||
|
(json.dumps(wd) if wd else None, json.dumps(pois) if pois else None, entry_id)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("EXIF-GPS Wetter/POI Fehler: %s", e)
|
||||||
|
|
||||||
|
resp = {"id": new_id, "url": media_url, "media_type": media_type,
|
||||||
"sort_order": max_order + 1, "is_cover": is_cover}
|
"sort_order": max_order + 1, "is_cover": is_cover}
|
||||||
|
if exif_gps:
|
||||||
|
resp["exif_lat"] = exif_gps[0]
|
||||||
|
resp["exif_lon"] = exif_gps[1]
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{dog_id}/diary/{entry_id}/media/{media_id}", status_code=204)
|
@router.delete("/{dog_id}/diary/{entry_id}/media/{media_id}", status_code=204)
|
||||||
|
|
@ -587,3 +773,55 @@ async def set_cover_media(dog_id: int, entry_id: int, media_id: int,
|
||||||
conn.execute("UPDATE diary_media SET is_cover=0 WHERE diary_id=?", (entry_id,))
|
conn.execute("UPDATE diary_media SET is_cover=0 WHERE diary_id=?", (entry_id,))
|
||||||
conn.execute("UPDATE diary_media SET is_cover=1 WHERE id=?", (media_id,))
|
conn.execute("UPDATE diary_media SET is_cover=1 WHERE id=?", (media_id,))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Admin: retroaktive Metadaten-Anreicherung bestehender Einträge
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.post("/admin/enrich-metadata", status_code=200)
|
||||||
|
async def admin_enrich_diary_metadata(limit: int = 20, _=Depends(require_admin)):
|
||||||
|
"""Reichert bestehende Tagebucheinträge mit GPS-Koordinaten mit Wetter + POI nach."""
|
||||||
|
with db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT id, gps_lat, gps_lon FROM diary
|
||||||
|
WHERE gps_lat IS NOT NULL AND gps_lon IS NOT NULL
|
||||||
|
AND (weather_json IS NULL OR poi_json IS NULL)
|
||||||
|
LIMIT ?""",
|
||||||
|
(limit,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
enriched = 0
|
||||||
|
skipped = 0
|
||||||
|
for row in rows:
|
||||||
|
entry_id, lat, lon = row["id"], row["gps_lat"], row["gps_lon"]
|
||||||
|
weather_json = None
|
||||||
|
poi_json = None
|
||||||
|
try:
|
||||||
|
wd = await weather_mod.get_weather_for_location(lat, lon)
|
||||||
|
weather_json = json.dumps(wd)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("enrich-metadata Wetter id=%s: %s", entry_id, e)
|
||||||
|
try:
|
||||||
|
pois = await _fetch_pois_for_coords(lat, lon, limit=5)
|
||||||
|
if pois:
|
||||||
|
poi_json = json.dumps(pois)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("enrich-metadata POI id=%s: %s", entry_id, e)
|
||||||
|
|
||||||
|
if weather_json is not None or poi_json is not None:
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE diary SET weather_json=COALESCE(weather_json,?), poi_json=COALESCE(poi_json,?) WHERE id=?",
|
||||||
|
(weather_json, poi_json, entry_id)
|
||||||
|
)
|
||||||
|
enriched += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
with db() as conn:
|
||||||
|
remaining = conn.execute(
|
||||||
|
"""SELECT COUNT(*) FROM diary
|
||||||
|
WHERE gps_lat IS NOT NULL AND (weather_json IS NULL OR poi_json IS NULL)"""
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
return {"enriched": enriched, "skipped": skipped, "remaining": remaining}
|
||||||
|
|
|
||||||
|
|
@ -155,9 +155,9 @@ async def import_notestation(
|
||||||
(entry_id, dog_id),
|
(entry_id, dog_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Erstes Bild speichern
|
# Anhänge in diary_media speichern (statt veraltetem media_url-Feld)
|
||||||
attachments = note.get("attachment") or {}
|
attachments = note.get("attachment") or {}
|
||||||
media_url = None
|
first = True
|
||||||
for att in attachments.values():
|
for att in attachments.values():
|
||||||
md5 = att.get("md5", "")
|
md5 = att.get("md5", "")
|
||||||
mime = att.get("type", "image/jpeg")
|
mime = att.get("type", "image/jpeg")
|
||||||
|
|
@ -165,13 +165,11 @@ async def import_notestation(
|
||||||
continue
|
continue
|
||||||
media_url = _save_image_from_zip(zf, md5, mime)
|
media_url = _save_image_from_zip(zf, md5, mime)
|
||||||
if media_url:
|
if media_url:
|
||||||
break
|
conn.execute(
|
||||||
|
"INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)",
|
||||||
if media_url:
|
(entry_id, media_url, "image", 0 if first else 1, 1 if first else 0),
|
||||||
conn.execute(
|
)
|
||||||
"UPDATE diary SET media_url=? WHERE id=?",
|
first = False
|
||||||
(media_url, entry_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
imported += 1
|
imported += 1
|
||||||
|
|
||||||
|
|
|
||||||
263
backend/routes/notes.py
Normal file
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
|
erfahrung: Optional[str] = None
|
||||||
social_link: Optional[str] = None
|
social_link: Optional[str] = None
|
||||||
profil_sichtbarkeit: Optional[str] = None
|
profil_sichtbarkeit: Optional[str] = None
|
||||||
|
notes_ki_enabled: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
def _load_user(user_id: int) -> dict:
|
def _load_user(user_id: int) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -90,14 +90,6 @@ def start():
|
||||||
id="seed_wikidata_startup",
|
id="seed_wikidata_startup",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
# Täglich 02:30 Uhr — KI-Anreicherung für 20 noch nicht angereicherte Rassen
|
|
||||||
_scheduler.add_job(
|
|
||||||
_job_wiki_enrich,
|
|
||||||
CronTrigger(hour=2, minute=30),
|
|
||||||
id="wiki_enrich_nightly",
|
|
||||||
replace_existing=True,
|
|
||||||
misfire_grace_time=3600,
|
|
||||||
)
|
|
||||||
# Jeden Montag 09:00 — Wöchentlicher Fortschritts-Lober
|
# Jeden Montag 09:00 — Wöchentlicher Fortschritts-Lober
|
||||||
_scheduler.add_job(
|
_scheduler.add_job(
|
||||||
_job_weekly_praise,
|
_job_weekly_praise,
|
||||||
|
|
@ -114,16 +106,8 @@ def start():
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=1800,
|
misfire_grace_time=1800,
|
||||||
)
|
)
|
||||||
# Einmalig beim Start (nach 90s) — erste 50 Rassen sofort anreichern
|
|
||||||
_scheduler.add_job(
|
|
||||||
_job_wiki_enrich_startup,
|
|
||||||
'date',
|
|
||||||
run_date=datetime.now(tz=_TZ) + timedelta(seconds=90),
|
|
||||||
id="wiki_enrich_startup",
|
|
||||||
replace_existing=True,
|
|
||||||
)
|
|
||||||
_scheduler.start()
|
_scheduler.start()
|
||||||
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start, Wiki-KI-Anreicherung 02:30.")
|
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start.")
|
||||||
|
|
||||||
|
|
||||||
def stop():
|
def stop():
|
||||||
|
|
@ -629,35 +613,6 @@ def _log_job(job_id: str, status: str, result: str):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# JOB: KI-Anreicherung der Rassen-Daten (nächtlich)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
async def _job_wiki_enrich():
|
|
||||||
"""Reichert alle noch nicht angereicherten Rassen mit KI-Daten an."""
|
|
||||||
try:
|
|
||||||
from scraper.breed_enricher import enrich_breeds
|
|
||||||
enriched = await enrich_breeds(limit=2000)
|
|
||||||
msg = f"{enriched} Rassen angereichert"
|
|
||||||
logger.info(f"Wiki-KI-Anreicherung (nächtlich): {msg}.")
|
|
||||||
_log_job("wiki_enrich_nightly", "ok", msg)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Wiki-KI-Anreicherung: Fehler: {e}")
|
|
||||||
_log_job("wiki_enrich_nightly", "error", str(e))
|
|
||||||
|
|
||||||
|
|
||||||
async def _job_wiki_enrich_startup():
|
|
||||||
"""Beim Start: alle Rassen sofort anreichern."""
|
|
||||||
try:
|
|
||||||
from scraper.breed_enricher import enrich_breeds
|
|
||||||
enriched = await enrich_breeds(limit=2000)
|
|
||||||
msg = f"{enriched} Rassen angereichert (Startup)"
|
|
||||||
logger.info(f"Wiki-KI-Anreicherung (Startup): {msg}.")
|
|
||||||
_log_job("wiki_enrich_startup", "ok", msg)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Wiki-KI-Anreicherung (Startup): Fehler: {e}")
|
|
||||||
_log_job("wiki_enrich_startup", "error", str(e))
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Hilfsfunktion: Lob-Text für einen Hund generieren
|
# Hilfsfunktion: Lob-Text für einen Hund generieren
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -815,12 +770,6 @@ async def _job_status_report():
|
||||||
metrics = {}
|
metrics = {}
|
||||||
try:
|
try:
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
# Rassen-Anreicherung
|
|
||||||
metrics["rassen_total"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen").fetchone()[0]
|
|
||||||
metrics["rassen_enriched"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=1").fetchone()[0]
|
|
||||||
metrics["rassen_mit_foto"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE foto_url IS NOT NULL AND foto_url NOT LIKE 'http%'").fetchone()[0]
|
|
||||||
metrics["rassen_mit_desc"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE beschreibung IS NOT NULL AND beschreibung != ''").fetchone()[0]
|
|
||||||
|
|
||||||
# Züchter
|
# Züchter
|
||||||
try:
|
try:
|
||||||
metrics["zuchter_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0").fetchone()[0]
|
metrics["zuchter_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0").fetchone()[0]
|
||||||
|
|
@ -848,16 +797,6 @@ async def _job_status_report():
|
||||||
logger.error(f"Status-Report: DB-Fehler: {e}")
|
logger.error(f"Status-Report: DB-Fehler: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- Wiki-Fortschritt berechnen ---
|
|
||||||
total = metrics["rassen_total"] or 1
|
|
||||||
enriched = metrics["rassen_enriched"]
|
|
||||||
pct = round(enriched / total * 100)
|
|
||||||
remaining = total - enriched
|
|
||||||
nights_left = (remaining + 19) // 20 # bei 20/Nacht
|
|
||||||
|
|
||||||
bar_filled = round(pct / 5)
|
|
||||||
progress_bar = "█" * bar_filled + "░" * (20 - bar_filled)
|
|
||||||
|
|
||||||
# --- Job-Log-Tabelle ---
|
# --- Job-Log-Tabelle ---
|
||||||
job_labels = {
|
job_labels = {
|
||||||
"health_reminders": "Gesundheits-Erinnerungen",
|
"health_reminders": "Gesundheits-Erinnerungen",
|
||||||
|
|
@ -865,8 +804,6 @@ async def _job_status_report():
|
||||||
"weather_alert": "Wetter-Alert",
|
"weather_alert": "Wetter-Alert",
|
||||||
"milestone_check": "Meilenstein-Check",
|
"milestone_check": "Meilenstein-Check",
|
||||||
"import_events": "Event-Import (VDH)",
|
"import_events": "Event-Import (VDH)",
|
||||||
"wiki_enrich_nightly": "Wiki KI-Anreicherung (nächtlich)",
|
|
||||||
"wiki_enrich_startup": "Wiki KI-Anreicherung (Startup)",
|
|
||||||
"seed_breeds_startup": "Rassen-Seed (TheDogAPI)",
|
"seed_breeds_startup": "Rassen-Seed (TheDogAPI)",
|
||||||
"seed_wikidata_startup":"Rassen-Seed (Wikidata)",
|
"seed_wikidata_startup":"Rassen-Seed (Wikidata)",
|
||||||
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
|
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
|
||||||
|
|
@ -899,18 +836,6 @@ async def _job_status_report():
|
||||||
<div style="opacity:.88;font-size:13px">{now_str} Uhr</div>
|
<div style="opacity:.88;font-size:13px">{now_str} Uhr</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Wiki-Fortschritt -->
|
|
||||||
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
|
|
||||||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Wiki KI-Anreicherung</div>
|
|
||||||
<div style="font-family:monospace;font-size:13px;background:#fdf6ef;border-radius:8px;padding:12px 14px;line-height:1.8">
|
|
||||||
<span style="color:#555">{progress_bar}</span> <strong>{pct}%</strong><br>
|
|
||||||
✅ Angereichert: <strong>{enriched}</strong> / {total}<br>
|
|
||||||
⏳ Verbleibend: <strong>{remaining}</strong> Rassen (~{nights_left} Nächte)<br>
|
|
||||||
📷 Mit lokalem Foto: <strong>{metrics['rassen_mit_foto']}</strong><br>
|
|
||||||
📝 Mit Beschreibung: <strong>{metrics['rassen_mit_desc']}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scheduler-Status -->
|
<!-- Scheduler-Status -->
|
||||||
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
|
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
|
||||||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Scheduler-Jobs</div>
|
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Scheduler-Jobs</div>
|
||||||
|
|
@ -947,13 +872,6 @@ async def _job_status_report():
|
||||||
|
|
||||||
plain = f"""Ban Yaro Status-Report — {now_str}
|
plain = f"""Ban Yaro Status-Report — {now_str}
|
||||||
|
|
||||||
=== Wiki KI-Anreicherung ===
|
|
||||||
{progress_bar} {pct}%
|
|
||||||
Angereichert: {enriched}/{total}
|
|
||||||
Verbleibend: {remaining} Rassen (~{nights_left} Nächte à 20/Nacht)
|
|
||||||
Mit Foto: {metrics['rassen_mit_foto']}
|
|
||||||
Mit Beschreibung: {metrics['rassen_mit_desc']}
|
|
||||||
|
|
||||||
=== Scheduler-Jobs ===
|
=== Scheduler-Jobs ===
|
||||||
{job_rows_txt}
|
{job_rows_txt}
|
||||||
=== Community ===
|
=== Community ===
|
||||||
|
|
|
||||||
|
|
@ -963,82 +963,326 @@ html.modal-open {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------
|
/* ------------------------------------------------------------
|
||||||
12. TAGEBUCH
|
12. TAGEBUCH — Day One Style
|
||||||
------------------------------------------------------------ */
|
------------------------------------------------------------ */
|
||||||
|
|
||||||
/* Monats-Trennlinie */
|
/* Stats-Leiste */
|
||||||
.diary-month-header {
|
.diary-stats-bar {
|
||||||
font-size: var(--text-sm);
|
display: flex;
|
||||||
font-weight: var(--weight-semibold);
|
align-items: center;
|
||||||
color: var(--c-text-secondary);
|
justify-content: space-between;
|
||||||
text-transform: uppercase;
|
gap: 0;
|
||||||
letter-spacing: 0.06em;
|
padding: 8px 12px;
|
||||||
padding: var(--space-4) 0 var(--space-2);
|
border-bottom: 1px solid var(--c-divider, var(--c-border));
|
||||||
border-bottom: 1px solid var(--c-border);
|
background: var(--c-surface);
|
||||||
margin-bottom: var(--space-3);
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.diary-month-header:first-child {
|
.diary-stats-numbers {
|
||||||
padding-top: 0;
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.diary-stats-numbers::-webkit-scrollbar { display: none; }
|
||||||
|
.diary-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-right: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
.diary-stat:last-child { border-right: none; }
|
||||||
|
.diary-stat-num {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--c-text);
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.diary-stat-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Eintragskarte */
|
/* View-Switcher */
|
||||||
|
.diary-view-switcher {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.diary-view-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--c-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.diary-view-btn:hover { background: var(--c-surface-2); color: var(--c-text); }
|
||||||
|
.diary-view-btn.active { color: var(--c-primary); background: var(--c-primary-subtle); }
|
||||||
|
.diary-view-btn .ph-icon { width: 18px; height: 18px; }
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.diary-stat { padding: 0 12px; }
|
||||||
|
.diary-stat-num { font-size: 20px; }
|
||||||
|
.diary-view-btn { padding: 6px 8px; }
|
||||||
|
.diary-view-btn .ph-icon { width: 20px; height: 20px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meta-Zeile in der Karte */
|
||||||
|
.diary-meta-loc {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
max-width: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.diary-meta-dot { color: var(--c-text-muted); opacity: .5; }
|
||||||
|
|
||||||
|
/* Medien-Mosaic */
|
||||||
|
.diary-media-mosaic {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
.diary-mosaic-item {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.diary-mosaic-item img {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
transition: opacity .2s;
|
||||||
|
}
|
||||||
|
.diary-mosaic-item:hover img { opacity: .85; }
|
||||||
|
|
||||||
|
/* Kalender-Ansicht */
|
||||||
|
.diary-calendar { padding: 0 0 80px; }
|
||||||
|
.diary-cal-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 16px 8px;
|
||||||
|
}
|
||||||
|
.diary-cal-nav button {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
padding: 6px; border-radius: 8px; color: var(--c-text-muted);
|
||||||
|
display: flex; align-items: center;
|
||||||
|
}
|
||||||
|
.diary-cal-nav button:hover { background: var(--c-surface-2); }
|
||||||
|
.diary-cal-nav button .ph-icon { width: 20px; height: 20px; }
|
||||||
|
.diary-cal-month { font-size: 16px; font-weight: 600; color: var(--c-text); }
|
||||||
|
.diary-cal-weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
padding: 0 8px 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.diary-cal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 3px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
.diary-cal-cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-text-secondary);
|
||||||
|
}
|
||||||
|
.diary-cal-cell.has-entry {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--c-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.diary-cal-cell.has-entry:active { opacity: .7; }
|
||||||
|
/* Oranger Punkt unter der Tageszahl — sichtbar auch ohne Foto */
|
||||||
|
.diary-cal-cell.has-entry::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--c-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
/* Foto als Hintergrund */
|
||||||
|
.diary-cal-cell.has-entry img {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
opacity: .4;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.diary-cal-cell.has-entry:hover img,
|
||||||
|
.diary-cal-cell.has-entry:active img { opacity: .6; }
|
||||||
|
/* Punkt ausblenden wenn Foto vorhanden (Foto reicht als Indikator) */
|
||||||
|
.diary-cal-cell.has-entry:has(img)::after { display: none; }
|
||||||
|
.diary-cal-cell.today .diary-cal-day {
|
||||||
|
background: var(--c-primary);
|
||||||
|
color: var(--c-text-inverse);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 26px; height: 26px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.diary-cal-day { position: relative; z-index: 1; font-size: 13px; }
|
||||||
|
|
||||||
|
/* Monats-Section */
|
||||||
|
.diary-month-header {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--c-text);
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--c-surface-2, #f5f5f5);
|
||||||
|
margin: 0;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
border-bottom: none;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.diary-month-header:first-child {
|
||||||
|
border-top: none;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.diary-month-header { background: var(--c-surface-2); }
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .diary-month-header { background: var(--c-surface-2); }
|
||||||
|
|
||||||
|
/* Monats-Eintrags-Container (umschließt alle Karten einer Section) */
|
||||||
|
.diary-month-entries {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eintragskarte — Day One Row-Style */
|
||||||
.diary-card {
|
.diary-card {
|
||||||
background: var(--c-surface);
|
display: flex;
|
||||||
border: 1px solid var(--c-border);
|
align-items: flex-start;
|
||||||
border-radius: var(--radius-lg);
|
gap: 12px;
|
||||||
margin-bottom: var(--space-3);
|
padding: 14px 16px;
|
||||||
overflow: hidden;
|
background: transparent;
|
||||||
cursor: pointer;
|
border: none;
|
||||||
transition: box-shadow var(--transition-fast),
|
border-bottom: 1px solid var(--c-divider, var(--c-border));
|
||||||
transform var(--transition-fast);
|
border-radius: 0;
|
||||||
box-shadow: var(--shadow-xs);
|
margin-bottom: 0;
|
||||||
|
overflow: visible;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
box-shadow: none;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
.diary-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
.diary-card:hover {
|
.diary-card:hover {
|
||||||
box-shadow: var(--shadow-md);
|
background: rgba(0,0,0,0.025);
|
||||||
transform: translateY(-1px);
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
.diary-card:active {
|
.diary-card:active {
|
||||||
transform: scale(0.99);
|
background: rgba(0,0,0,0.05);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .diary-card:hover { background: rgba(255,255,255,0.04); }
|
||||||
|
[data-theme="dark"] .diary-card:active { background: rgba(255,255,255,0.07); }
|
||||||
|
|
||||||
|
/* Datum-Spalte links */
|
||||||
|
.diary-card-date-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
.diary-card-weekday {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.diary-card-daynum {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--c-text);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meilenstein-Icon auf der Datum-Spalte */
|
||||||
|
.diary-card-date-col .diary-milestone-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #c4a000;
|
||||||
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Meilenstein-Hervorhebung */
|
/* Meilenstein-Hervorhebung */
|
||||||
.diary-card--milestone {
|
.diary-card--milestone {
|
||||||
border-color: #d4a017;
|
background: color-mix(in srgb, #d4a017 4%, transparent);
|
||||||
border-width: 2px;
|
}
|
||||||
background: linear-gradient(
|
.diary-card--milestone .diary-card-daynum {
|
||||||
135deg,
|
color: #b8860b;
|
||||||
var(--c-surface) 0%,
|
|
||||||
color-mix(in srgb, #d4a017 8%, var(--c-surface)) 100%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Meilenstein-Badge innerhalb der Karte */
|
/* Meilenstein-Badge innerhalb der Karte */
|
||||||
.diary-card-milestone-badge {
|
.diary-card-milestone-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
background: color-mix(in srgb, #d4a017 15%, transparent);
|
background: color-mix(in srgb, #d4a017 15%, transparent);
|
||||||
color: #8a6400;
|
color: #8a6400;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
padding: 2px var(--space-2);
|
padding: 2px var(--space-2);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: 4px;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Foto / Video oben */
|
/* Foto / Thumbnail rechts — 72×72px */
|
||||||
.diary-card-photo {
|
.diary-card-photo {
|
||||||
width: 100%;
|
width: 72px;
|
||||||
height: 180px;
|
height: 72px;
|
||||||
overflow: hidden;
|
flex-shrink: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
.diary-card-photo img {
|
.diary-card-photo img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.diary-media-picker {
|
.diary-media-picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1165,7 +1409,7 @@ html.modal-open {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: none;
|
border: none;
|
||||||
background: rgba(0,0,0,.50);
|
background: rgba(0,0,0,.50);
|
||||||
color: #9ca3af;
|
color: rgba(255,255,255,.55);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1177,7 +1421,7 @@ html.modal-open {
|
||||||
transition: color .15s, background .15s;
|
transition: color .15s, background .15s;
|
||||||
}
|
}
|
||||||
.diary-cover-btn--active {
|
.diary-cover-btn--active {
|
||||||
color: #f5c518;
|
color: var(--c-amber);
|
||||||
background: rgba(0,0,0,.65);
|
background: rgba(0,0,0,.65);
|
||||||
}
|
}
|
||||||
.diary-cover-btn--form {
|
.diary-cover-btn--form {
|
||||||
|
|
@ -1185,48 +1429,46 @@ html.modal-open {
|
||||||
left: var(--space-1);
|
left: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card Body */
|
/* Card Body — mittlere Spalte */
|
||||||
.diary-card-body {
|
.diary-card-body {
|
||||||
padding: var(--space-3) var(--space-4);
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Meta-Zeile: Typ + Datum */
|
/* Titel in Karte */
|
||||||
.diary-card-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
.diary-card-type {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
color: var(--c-primary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.diary-card-date {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
color: var(--c-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Titel */
|
|
||||||
.diary-card-title {
|
.diary-card-title {
|
||||||
font-size: var(--text-base);
|
font-size: 15px;
|
||||||
font-weight: var(--weight-semibold);
|
font-weight: 700;
|
||||||
color: var(--c-text);
|
color: var(--c-text);
|
||||||
margin-bottom: var(--space-1);
|
margin: 0 0 3px;
|
||||||
|
line-height: 1.3;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Meta-Zeile: nur noch für Compat — im neuen Design nicht als flex-row genutzt */
|
||||||
|
.diary-card-meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.diary-card-type { display: none; }
|
||||||
|
.diary-card-date { display: none; }
|
||||||
|
|
||||||
/* Ort-Zeile in Karte */
|
/* Ort-Zeile in Karte */
|
||||||
.diary-card-location {
|
.diary-card-location {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-1);
|
gap: 4px;
|
||||||
font-size: var(--text-sm);
|
font-size: 12px;
|
||||||
color: var(--c-primary);
|
color: var(--c-text-muted);
|
||||||
margin: 0 0 var(--space-1);
|
margin: 0 0 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.diary-card-location .ph-icon { flex-shrink: 0; }
|
.diary-card-location .ph-icon { flex-shrink: 0; width: 12px; height: 12px; }
|
||||||
|
|
||||||
/* Ort in Detail-Ansicht */
|
/* Ort in Detail-Ansicht */
|
||||||
.diary-detail-location {
|
.diary-detail-location {
|
||||||
|
|
@ -1292,12 +1534,12 @@ html.modal-open {
|
||||||
|
|
||||||
/* Text-Vorschau */
|
/* Text-Vorschau */
|
||||||
.diary-card-text {
|
.diary-card-text {
|
||||||
font-size: var(--text-sm);
|
font-size: 13px;
|
||||||
color: var(--c-text-secondary);
|
color: var(--c-text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.45;
|
||||||
margin: 0 0 var(--space-2);
|
margin: 0 0 4px;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
@ -1310,6 +1552,62 @@ html.modal-open {
|
||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Meta-Zeile unten in der Karte: Zeit · Ort · Wetter */
|
||||||
|
.diary-card-meta-row {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wetter-Badge in Karten-Meta */
|
||||||
|
.diary-weather-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--c-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FAB — Floating Action Button */
|
||||||
|
.diary-fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(var(--nav-bottom-height, 64px) + env(safe-area-inset-bottom, 0px) + 16px);
|
||||||
|
right: 20px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--c-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 16px rgba(196,132,58,.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
transition: transform .15s, box-shadow .15s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.diary-fab:hover { transform: scale(1.06); box-shadow: 0 6px 20px rgba(196,132,58,.5); }
|
||||||
|
.diary-fab:active { transform: scale(0.94); }
|
||||||
|
|
||||||
|
/* POI-Chips in Karte und Detail */
|
||||||
|
.diary-poi-chips,
|
||||||
|
.diary-detail-poi-chips {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: var(--space-1) 0 var(--space-1);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
/* Detail-Ansicht */
|
/* Detail-Ansicht */
|
||||||
.diary-detail-milestone-badge {
|
.diary-detail-milestone-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
@ -1324,6 +1622,312 @@ html.modal-open {
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Detail-View: Hero-Bild */
|
||||||
|
.diary-detail-hero {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
|
background: #000;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.diary-detail-hero {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.diary-detail-hero { max-width: 1300px; }
|
||||||
|
}
|
||||||
|
.diary-detail-hero img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 80vh;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
.diary-detail-hero video {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 80vh;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail-View: inline im Content-Bereich (kein Overlay mehr) */
|
||||||
|
.diary-detail-view-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: calc(100vh - 120px);
|
||||||
|
background: var(--c-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail-View: Header-Bar */
|
||||||
|
.diary-detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
.diary-detail-back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--c-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.diary-detail-date-center {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--c-text);
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.diary-detail-edit {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--c-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail-View: Body-Wrapper (text links, Karte rechts auf Desktop) */
|
||||||
|
.diary-detail-body-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.diary-detail-body-wrap {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.diary-detail-body-wrap { max-width: 1300px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail-View: Inhalt */
|
||||||
|
.diary-detail-content {
|
||||||
|
padding: 24px 24px 60px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.diary-detail-content { padding: 20px 16px 40px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail-View: Karte + POI-Sektion */
|
||||||
|
.diary-detail-map-wrap {
|
||||||
|
padding: 16px 16px 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.diary-detail-map-wrap {
|
||||||
|
width: 380px;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 420px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 24px 0 40px 32px;
|
||||||
|
position: sticky;
|
||||||
|
top: 60px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.diary-detail-map {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.diary-detail-map { height: 280px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POI-Liste */
|
||||||
|
.diary-detail-poi-list {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.diary-detail-poi-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
padding: 10px 14px 8px;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
.diary-detail-poi-heading .ph-icon { width:14px;height:14px; }
|
||||||
|
.diary-detail-poi-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.diary-detail-poi-row:last-child { border-bottom: none; }
|
||||||
|
.diary-detail-poi-icon { width:16px;height:16px;color:var(--c-primary);flex-shrink:0; }
|
||||||
|
.diary-detail-poi-name { flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c-text); }
|
||||||
|
.diary-detail-poi-dist { font-size:12px;color:var(--c-text-muted);flex-shrink:0; }
|
||||||
|
.diary-detail-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--c-text);
|
||||||
|
margin: 0 0 16px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.diary-detail-body {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--c-text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
.diary-detail-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail-View: Meta-Bar unten */
|
||||||
|
.diary-detail-meta-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.diary-detail-meta-bar .ph-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.diary-detail-meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail-View: Thumbnail-Strip */
|
||||||
|
.diary-detail-thumbs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
background: rgba(0,0,0,.6);
|
||||||
|
flex-shrink: 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.diary-detail-thumbs {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
background: rgba(0,0,0,.75);
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.diary-detail-thumbs { max-width: 1300px; }
|
||||||
|
}
|
||||||
|
.diary-detail-thumbs::-webkit-scrollbar { display: none; }
|
||||||
|
.diary-detail-thumb {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color .15s, opacity .15s;
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
.diary-detail-thumb:hover { opacity: 1; }
|
||||||
|
.diary-detail-thumb--active {
|
||||||
|
border-color: var(--c-primary);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.diary-detail-thumb { width: 72px; height: 72px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail-View: Foto-Galerie horizontal */
|
||||||
|
.diary-detail-gallery {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
margin: 0 -20px 20px;
|
||||||
|
padding: 0 20px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.diary-detail-gallery::-webkit-scrollbar { display: none; }
|
||||||
|
.diary-detail-gallery-item {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: min(75vw, 280px);
|
||||||
|
height: 200px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
.diary-detail-gallery-item img,
|
||||||
|
.diary-detail-gallery-item video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* Leaflet-Attribution ausblenden */
|
/* Leaflet-Attribution ausblenden */
|
||||||
.leaflet-control-attribution { display: none !important; }
|
.leaflet-control-attribution { display: none !important; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
--c-warning: #D4923A;
|
--c-warning: #D4923A;
|
||||||
--c-warning-subtle: #FDF3E3;
|
--c-warning-subtle: #FDF3E3;
|
||||||
--c-amber: #E4A020; /* Goldgelb — "Heute"-Akzent, distinct von Primary */
|
--c-amber: #E4A020; /* Goldgelb — "Heute"-Akzent, distinct von Primary */
|
||||||
|
--c-icon: #7A6A58; /* Standard-Icon-Farbe (= text-secondary im Light-Mode) */
|
||||||
--c-info: #4A7A9B;
|
--c-info: #4A7A9B;
|
||||||
--c-info-subtle: #E8F2F8;
|
--c-info-subtle: #E8F2F8;
|
||||||
|
|
||||||
|
|
@ -137,8 +138,11 @@
|
||||||
|
|
||||||
--c-text: #F0EAE0;
|
--c-text: #F0EAE0;
|
||||||
--c-text-secondary: #C0B0A0;
|
--c-text-secondary: #C0B0A0;
|
||||||
--c-text-muted: #806A58;
|
--c-text-muted: #9A8878;
|
||||||
--c-text-inverse: #2A1F14;
|
--c-text-inverse: #2A1F14;
|
||||||
|
--c-icon: #B0A090;
|
||||||
|
--c-amber: #C48820;
|
||||||
|
--c-success: #6A9E58;
|
||||||
|
|
||||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30);
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30);
|
||||||
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.25);
|
--shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,7 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--c-text-muted);
|
color: var(--c-icon, var(--c-text-secondary));
|
||||||
transition: color var(--transition-fast);
|
transition: color var(--transition-fast);
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
touch-action: manipulation;
|
touch-action: manipulation;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,21 @@
|
||||||
<symbol id="map-trifold" viewBox="0 0 256 256"><path d="M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z"/></symbol>
|
<symbol id="map-trifold" viewBox="0 0 256 256"><path d="M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z"/></symbol>
|
||||||
<symbol id="path" viewBox="0 0 256 256"><path d="M200,168a32.06,32.06,0,0,0-31,24H72a32,32,0,0,1,0-64h96a40,40,0,0,0,0-80H72a8,8,0,0,0,0,16h96a24,24,0,0,1,0,48H72a48,48,0,0,0,0,96h97a32,32,0,1,0,31-40Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,200,216Z"/></symbol>
|
<symbol id="path" viewBox="0 0 256 256"><path d="M200,168a32.06,32.06,0,0,0-31,24H72a32,32,0,0,1,0-64h96a40,40,0,0,0,0-80H72a8,8,0,0,0,0,16h96a24,24,0,0,1,0,48H72a48,48,0,0,0,0,96h97a32,32,0,1,0,31-40Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,200,216Z"/></symbol>
|
||||||
<symbol id="paw-print" viewBox="0 0 256 256"><path d="M212,80a28,28,0,1,0,28,28A28,28,0,0,0,212,80Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,212,120ZM72,108a28,28,0,1,0-28,28A28,28,0,0,0,72,108ZM44,120a12,12,0,1,1,12-12A12,12,0,0,1,44,120ZM92,88A28,28,0,1,0,64,60,28,28,0,0,0,92,88Zm0-40A12,12,0,1,1,80,60,12,12,0,0,1,92,48Zm72,40a28,28,0,1,0-28-28A28,28,0,0,0,164,88Zm0-40a12,12,0,1,1-12,12A12,12,0,0,1,164,48Zm23.12,100.86a35.3,35.3,0,0,1-16.87-21.14,44,44,0,0,0-84.5,0A35.25,35.25,0,0,1,69,148.82,40,40,0,0,0,88,224a39.48,39.48,0,0,0,15.52-3.13,64.09,64.09,0,0,1,48.87,0,40,40,0,0,0,34.73-72ZM168,208a24,24,0,0,1-9.45-1.93,80.14,80.14,0,0,0-61.19,0,24,24,0,0,1-20.71-43.26,51.22,51.22,0,0,0,24.46-30.67,28,28,0,0,1,53.78,0,51.27,51.27,0,0,0,24.53,30.71A24,24,0,0,1,168,208Z"/></symbol>
|
<symbol id="paw-print" viewBox="0 0 256 256"><path d="M212,80a28,28,0,1,0,28,28A28,28,0,0,0,212,80Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,212,120ZM72,108a28,28,0,1,0-28,28A28,28,0,0,0,72,108ZM44,120a12,12,0,1,1,12-12A12,12,0,0,1,44,120ZM92,88A28,28,0,1,0,64,60,28,28,0,0,0,92,88Zm0-40A12,12,0,1,1,80,60,12,12,0,0,1,92,48Zm72,40a28,28,0,1,0-28-28A28,28,0,0,0,164,88Zm0-40a12,12,0,1,1-12,12A12,12,0,0,1,164,48Zm23.12,100.86a35.3,35.3,0,0,1-16.87-21.14,44,44,0,0,0-84.5,0A35.25,35.25,0,0,1,69,148.82,40,40,0,0,0,88,224a39.48,39.48,0,0,0,15.52-3.13,64.09,64.09,0,0,1,48.87,0,40,40,0,0,0,34.73-72ZM168,208a24,24,0,0,1-9.45-1.93,80.14,80.14,0,0,0-61.19,0,24,24,0,0,1-20.71-43.26,51.22,51.22,0,0,0,24.46-30.67,28,28,0,0,1,53.78,0,51.27,51.27,0,0,0,24.53,30.71A24,24,0,0,1,168,208Z"/></symbol>
|
||||||
|
<symbol id="note-pencil" viewBox="0 0 256 256"><path d="M224,128v80a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32h80a8,8,0,0,1,0,16H48V208H208V128a8,8,0,0,1,16,0Zm5.66-58.34-96,96A8,8,0,0,1,128,168H96a8,8,0,0,1-8-8V128a8,8,0,0,1,2.34-5.66l96-96a8,8,0,0,1,11.32,0l32,32A8,8,0,0,1,229.66,69.66Zm-17-5.66L192,43.31,179.31,56,200,76.69Z"/></symbol>
|
||||||
|
<symbol id="images" viewBox="0 0 256 256"><path d="M216,40H72A16,16,0,0,0,56,56V72H40A16,16,0,0,0,24,88V200a16,16,0,0,0,16,16H184a16,16,0,0,0,16-16V184h16a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM172,72a12,12,0,1,1-12,12A12,12,0,0,1,172,72Zm12,128H40V88H56v80a16,16,0,0,0,16,16H184Zm32-32H72V120.69l30.34-30.35a8,8,0,0,1,11.32,0L163.31,140,189,114.34a8,8,0,0,1,11.31,0L216,130.07V168Z"/></symbol>
|
||||||
|
<symbol id="caret-left" viewBox="0 0 256 256"><path d="M163.06,40.61a8,8,0,0,0-8.72,1.73l-80,80a8,8,0,0,0,0,11.32l80,80A8,8,0,0,0,168,208V48A8,8,0,0,0,163.06,40.61Z"/></symbol>
|
||||||
|
<symbol id="caret-right" viewBox="0 0 256 256"><path d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34Z"/></symbol>
|
||||||
|
<symbol id="coffee" viewBox="0 0 256 256"><path d="M208,80H32a8,8,0,0,0-8,8v48a96.3,96.3,0,0,0,32.54,72H32a8,8,0,0,0,0,16H208a8,8,0,0,0,0-16H183.46a96.59,96.59,0,0,0,27-40.09A40,40,0,0,0,248,128v-8A40,40,0,0,0,208,80Zm24,48a24,24,0,0,1-17.2,23,95.78,95.78,0,0,0,1.2-15V97.38A24,24,0,0,1,232,120ZM112,56V24a8,8,0,0,1,16,0V56a8,8,0,0,1-16,0Zm32,0V24a8,8,0,0,1,16,0V56a8,8,0,0,1-16,0ZM80,56V24a8,8,0,0,1,16,0V56a8,8,0,0,1-16,0Z"/></symbol>
|
||||||
|
<symbol id="beer-bottle" viewBox="0 0 256 256"><path d="M245.66,42.34l-32-32a8,8,0,0,0-11.32,11.32l1.48,1.47L148.65,64.51l-38.22,7.65a8.05,8.05,0,0,0-4.09,2.18L23,157.66a24,24,0,0,0,0,33.94L64.4,233a24,24,0,0,0,33.94,0l83.32-83.31a8,8,0,0,0,2.18-4.09l7.65-38.22,41.38-55.17,1.47,1.48a8,8,0,0,0,11.32-11.32ZM81.37,224a7.94,7.94,0,0,1-5.65-2.34L34.34,180.28a8,8,0,0,1,0-11.31L40,163.31,92.69,216,87,221.66A8,8,0,0,1,81.37,224ZM177.6,99.2a7.92,7.92,0,0,0-1.44,3.23l-7.53,37.63L160,148.69,107.31,96l8.63-8.63,37.63-7.53a7.92,7.92,0,0,0,3.23-1.44l58.45-43.84,6.19,6.19Z"/></symbol>
|
||||||
|
<symbol id="shopping-bag" viewBox="0 0 256 256"><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-88,96A48.05,48.05,0,0,1,80,88a8,8,0,0,1,16,0,32,32,0,0,0,64,0,8,8,0,0,1,16,0A48.05,48.05,0,0,1,128,136Z"/></symbol>
|
||||||
|
<symbol id="binoculars" viewBox="0 0 256 256"><path d="M237.22,151.9l0-.1a1.42,1.42,0,0,0-.07-.22,48.46,48.46,0,0,0-2.31-5.3L193.27,51.8a8,8,0,0,0-1.67-2.44,32,32,0,0,0-45.26,0A8,8,0,0,0,144,55V80H112V55a8,8,0,0,0-2.34-5.66,32,32,0,0,0-45.26,0,8,8,0,0,0-1.67,2.44L21.2,146.28a48.46,48.46,0,0,0-2.31,5.3,1.72,1.72,0,0,0-.07.21s0,.08,0,.11a48,48,0,0,0,90.32,32.51,47.49,47.49,0,0,0,2.9-16.59V96h32v71.83a47.49,47.49,0,0,0,2.9,16.59,48,48,0,0,0,90.32-32.51Zm-143.15,27a32,32,0,0,1-60.2-21.71l1.81-4.13A32,32,0,0,1,96,167.88V168h0A32,32,0,0,1,94.07,178.94ZM203,198.07A32,32,0,0,1,160,168h0v-.11a32,32,0,0,1,60.32-14.78l1.81,4.13A32,32,0,0,1,203,198.07Z"/></symbol>
|
||||||
|
<symbol id="buildings" viewBox="0 0 256 256"><path d="M239.73,208H224V96a16,16,0,0,0-16-16H164a4,4,0,0,0-4,4V208H144V32.41a16.43,16.43,0,0,0-6.16-13,16,16,0,0,0-18.72-.69L39.12,72A16,16,0,0,0,32,85.34V208H16.27A8.18,8.18,0,0,0,8,215.47,8,8,0,0,0,16,224H240a8,8,0,0,0,8-8.53A8.18,8.18,0,0,0,239.73,208ZM76,184a8,8,0,0,1-8.53,8A8.18,8.18,0,0,1,60,183.72V168.27A8.19,8.19,0,0,1,67.47,160,8,8,0,0,1,76,168Zm0-56a8,8,0,0,1-8.53,8A8.19,8.19,0,0,1,60,127.72V112.27A8.19,8.19,0,0,1,67.47,104,8,8,0,0,1,76,112Zm40,56a8,8,0,0,1-8.53,8,8.18,8.18,0,0,1-7.47-8.26V168.27a8.19,8.19,0,0,1,7.47-8.26,8,8,0,0,1,8.53,8Zm0-56a8,8,0,0,1-8.53,8,8.19,8.19,0,0,1-7.47-8.26V112.27a8.19,8.19,0,0,1,7.47-8.26,8,8,0,0,1,8.53,8Z"/></symbol>
|
||||||
|
<symbol id="bed" viewBox="0 0 256 256"><path d="M216,72H32V48a8,8,0,0,0-16,0V208a8,8,0,0,0,16,0V176H240v32a8,8,0,0,0,16,0V112A40,40,0,0,0,216,72ZM32,88h72v72H32Z"/></symbol>
|
||||||
|
<symbol id="church" viewBox="0 0 256 256"><path d="M228.12,145.14,192,123.47V104a8,8,0,0,0-4-7L136,67.36V48h16a8,8,0,0,0,0-16H136V16a8,8,0,0,0-16,0V32H104a8,8,0,0,0,0,16h16V67.36L68,97.05a8,8,0,0,0-4,7v19.47L27.88,145.14A8,8,0,0,0,24,152v64a8,8,0,0,0,8,8h72a8,8,0,0,0,8-8V168a16,16,0,0,1,32,0v48a8,8,0,0,0,8,8h72a8,8,0,0,0,8-8V152A8,8,0,0,0,228.12,145.14ZM64,208H40V156.53l24-14.4Zm152,0H192V142.13l24,14.4Z"/></symbol>
|
||||||
|
<symbol id="soccer-ball" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm8,39.38,24.79-17.05a88.41,88.41,0,0,1,36.18,27l-8,26.94c-.2,0-.41.1-.61.17l-22.82,7.41a7.59,7.59,0,0,0-1,.4L136,88.62c0-.2,0-.41,0-.62V64C136,63.79,136,63.58,136,63.38ZM95.24,46.33,120,63.38c0,.2,0,.41,0,.62V88c0,.21,0,.42,0,.62L91.44,108.29a7.59,7.59,0,0,0-1-.4l-22.82-7.41c-.2-.07-.41-.12-.61-.17l-8-26.94A88.41,88.41,0,0,1,95.24,46.33Zm-13,129.09H53.9a87.4,87.4,0,0,1-13.79-43.07l22-16.88a5.77,5.77,0,0,0,.58.22l22.83,7.42a7.83,7.83,0,0,0,.93.22l10.79,31.42c-.15.18-.3.36-.44.55L82.7,174.71A7.8,7.8,0,0,0,82.24,175.42ZM150.69,213a88.16,88.16,0,0,1-45.38,0L95.25,184.6c.13-.16.27-.31.39-.48l14.11-19.42a7.66,7.66,0,0,0,.46-.7h35.58a7.66,7.66,0,0,0,.46.7l14.11,19.42c.12.17.26.32.39.48Zm23.07-37.61a7.8,7.8,0,0,0-.46-.71L159.19,155.3c-.14-.19-.29-.37-.44-.55l10.79-31.42a7.83,7.83,0,0,0,.93-.22l22.83-7.42a5.77,5.77,0,0,0,.58-.22l22,16.88a87.4,87.4,0,0,1-13.79,43.07Z"/></symbol>
|
||||||
|
<symbol id="tree" viewBox="0 0 256 256"><path d="M128,187.85a72.44,72.44,0,0,0,8,4.62V232a8,8,0,0,1-16,0V192.47A72.44,72.44,0,0,0,128,187.85ZM198.1,62.59a76,76,0,0,0-140.2,0A71.71,71.71,0,0,0,16,127.8C15.9,166,48,199,86.14,200A72.22,72.22,0,0,0,120,192.47V156.94L76.42,135.16a8,8,0,1,1,7.16-14.32L120,139.06V88a8,8,0,0,1,16,0v27.06l36.42-18.22a8,8,0,1,1,7.16,14.32L136,132.94v59.53A72.17,72.17,0,0,0,168,200l1.82,0C208,199,240.11,166,240,127.8A71.71,71.71,0,0,0,198.1,62.59Z"/></symbol>
|
||||||
|
<symbol id="caret-double-left" viewBox="0 0 256 256"><path d="M203.06,40.61a8,8,0,0,0-8.72,1.73L128,108.69V48a8,8,0,0,0-13.66-5.66l-80,80a8,8,0,0,0,0,11.32l80,80A8,8,0,0,0,128,208V147.31l66.34,66.35A8,8,0,0,0,208,208V48A8,8,0,0,0,203.06,40.61Z"/></symbol>
|
||||||
|
<symbol id="caret-double-right" viewBox="0 0 256 256"><path d="M221.66,122.34l-80-80A8,8,0,0,0,128,48v60.69L61.66,42.34A8,8,0,0,0,48,48V208a8,8,0,0,0,13.66,5.66L128,147.31V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,221.66,122.34Z"/></symbol>
|
||||||
<symbol id="pencil-simple" viewBox="0 0 256 256"><path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z"/></symbol>
|
<symbol id="pencil-simple" viewBox="0 0 256 256"><path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z"/></symbol>
|
||||||
<symbol id="plus" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></symbol>
|
<symbol id="plus" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"/></symbol>
|
||||||
<symbol id="spinner" viewBox="0 0 256 256"><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm37.25,58.75a8,8,0,0,0,5.66-2.35l22.63-22.62a8,8,0,0,0-11.32-11.32L167.6,77.09a8,8,0,0,0,5.65,13.66ZM224,120H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z"/></symbol>
|
<symbol id="spinner" viewBox="0 0 256 256"><path d="M136,32V64a8,8,0,0,1-16,0V32a8,8,0,0,1,16,0Zm37.25,58.75a8,8,0,0,0,5.66-2.35l22.63-22.62a8,8,0,0,0-11.32-11.32L167.6,77.09a8,8,0,0,0,5.65,13.66ZM224,120H192a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm-45.09,47.6a8,8,0,0,0-11.31,11.31l22.62,22.63a8,8,0,0,0,11.32-11.32ZM128,184a8,8,0,0,0-8,8v32a8,8,0,0,0,16,0V192A8,8,0,0,0,128,184ZM77.09,167.6,54.46,190.22a8,8,0,0,0,11.32,11.32L88.4,178.91A8,8,0,0,0,77.09,167.6ZM72,128a8,8,0,0,0-8-8H32a8,8,0,0,0,0,16H64A8,8,0,0,0,72,128ZM65.78,54.46A8,8,0,0,0,54.46,65.78L77.09,88.4A8,8,0,0,0,88.4,77.09Z"/></symbol>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 41 KiB |
|
|
@ -88,9 +88,9 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||||
<link rel="stylesheet" href="/css/design-system.css">
|
<link rel="stylesheet" href="/css/design-system.css?v=382">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=237">
|
<link rel="stylesheet" href="/css/layout.css?v=382">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=232">
|
<link rel="stylesheet" href="/css/components.css?v=382">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -124,6 +124,9 @@
|
||||||
<div class="sidebar-item" data-page="trainingsplaene">
|
<div class="sidebar-item" data-page="trainingsplaene">
|
||||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg> Trainingspläne
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#clipboard-text"></use></svg> Trainingspläne
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sidebar-item" data-page="notes">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notizblock
|
||||||
|
</div>
|
||||||
|
|
||||||
<span class="sidebar-section-label">Entdecken</span>
|
<span class="sidebar-section-label">Entdecken</span>
|
||||||
<div class="sidebar-item" data-page="map">
|
<div class="sidebar-item" data-page="map">
|
||||||
|
|
@ -371,6 +374,10 @@
|
||||||
<div class="page-body page-container"></div>
|
<div class="page-body page-container"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="page" id="page-notes">
|
||||||
|
<div class="page-body page-container"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- MOBILE BOTTOM NAVIGATION -->
|
<!-- MOBILE BOTTOM NAVIGATION -->
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ const API = (() => {
|
||||||
const q = new URLSearchParams(params).toString();
|
const q = new URLSearchParams(params).toString();
|
||||||
return get(`/dogs/${dogId}/diary${q ? '?' + q : ''}`);
|
return get(`/dogs/${dogId}/diary${q ? '?' + q : ''}`);
|
||||||
},
|
},
|
||||||
|
stats(dogId) { return get(`/dogs/${dogId}/diary/stats`); },
|
||||||
get(dogId, entryId) { return get(`/dogs/${dogId}/diary/${entryId}`); },
|
get(dogId, entryId) { return get(`/dogs/${dogId}/diary/${entryId}`); },
|
||||||
create(dogId, data) { return post(`/dogs/${dogId}/diary`, data); },
|
create(dogId, data) { return post(`/dogs/${dogId}/diary`, data); },
|
||||||
update(dogId, id, data){ return patch(`/dogs/${dogId}/diary/${id}`, data); },
|
update(dogId, id, data){ return patch(`/dogs/${dogId}/diary/${id}`, data); },
|
||||||
|
|
@ -137,6 +138,8 @@ const API = (() => {
|
||||||
nearby(dogId, lat, lon) {
|
nearby(dogId, lat, lon) {
|
||||||
return get(`/dogs/${dogId}/diary/nearby?lat=${lat}&lon=${lon}`);
|
return get(`/dogs/${dogId}/diary/nearby?lat=${lat}&lon=${lon}`);
|
||||||
},
|
},
|
||||||
|
locations(dogId) { return get(`/dogs/${dogId}/diary/locations`); },
|
||||||
|
calendar(dogId) { return get(`/dogs/${dogId}/diary/calendar`); },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -559,6 +562,30 @@ const API = (() => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// NOTIZEN
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const notes = {
|
||||||
|
get(parentType, parentId) {
|
||||||
|
return get(`/notes/${parentType}/${parentId}`);
|
||||||
|
},
|
||||||
|
getAll(params) {
|
||||||
|
return get('/notes?' + new URLSearchParams(params || {}).toString());
|
||||||
|
},
|
||||||
|
analyse() {
|
||||||
|
return post('/notes/ki-analyse', {});
|
||||||
|
},
|
||||||
|
create(parentType, parentId, data) {
|
||||||
|
return post(`/notes/${parentType}/${parentId}`, data);
|
||||||
|
},
|
||||||
|
update(id, data) {
|
||||||
|
return patch(`/notes/${id}`, data);
|
||||||
|
},
|
||||||
|
delete(id) {
|
||||||
|
return del(`/notes/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// ERROR-KLASSE
|
// ERROR-KLASSE
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -576,7 +603,7 @@ const API = (() => {
|
||||||
get, post, put, patch, del, upload,
|
get, post, put, patch, del, upload,
|
||||||
auth, dogs, diary, health, tieraerzte, poison,
|
auth, dogs, diary, health, tieraerzte, poison,
|
||||||
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
|
||||||
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training,
|
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,
|
||||||
subscribeToPush, getLocation,
|
subscribeToPush, getLocation,
|
||||||
APIError,
|
APIError,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '351'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '385'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
|
|
||||||
const App = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -28,6 +28,10 @@ window.Page_dog_profile = (() => {
|
||||||
if (e.target.closest('#profile-goto-login')) {
|
if (e.target.closest('#profile-goto-login')) {
|
||||||
App.navigate('settings');
|
App.navigate('settings');
|
||||||
}
|
}
|
||||||
|
if (e.target.closest('[data-action="goto-weight"]')) {
|
||||||
|
App.navigate('health', true, { tab: 'gewicht', openForm: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await _render();
|
await _render();
|
||||||
|
|
@ -119,7 +123,7 @@ window.Page_dog_profile = (() => {
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
${dog.gewicht_kg ? `
|
${dog.gewicht_kg ? `
|
||||||
<div class="card" style="padding:var(--space-3)">
|
<div class="card" style="padding:var(--space-3);cursor:pointer" data-action="goto-weight">
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||||
margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg> Gewicht</div>
|
margin-bottom:2px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#scales"></use></svg> Gewicht</div>
|
||||||
<div style="font-weight:500;font-size:var(--text-sm)">${dog.gewicht_kg} kg</div>
|
<div style="font-weight:500;font-size:var(--text-sm)">${dog.gewicht_kg} kg</div>
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ window.Page_erste_hilfe = (() => {
|
||||||
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold)">Bedeutung</th>
|
<th style="padding:var(--space-2) var(--space-3);text-align:left;font-weight:var(--weight-semibold)">Bedeutung</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Rosa, feucht</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:#22c55e;font-weight:var(--weight-semibold)">Normal</td></tr>
|
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Rosa, feucht</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-success);font-weight:var(--weight-semibold)">Normal</td></tr>
|
||||||
<tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Blass / weiß</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-danger)">Schock, Blutverlust, Vergiftung</td></tr>
|
<tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Blass / weiß</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-danger)">Schock, Blutverlust, Vergiftung</td></tr>
|
||||||
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Blau / grau</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-danger);font-weight:var(--weight-semibold)">Sauerstoffmangel — NOTFALL</td></tr>
|
<tr><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Blau / grau</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-danger);font-weight:var(--weight-semibold)">Sauerstoffmangel — NOTFALL</td></tr>
|
||||||
<tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Gelb</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-warning)">Leberprobleme</td></tr>
|
<tr style="background:var(--c-surface-2)"><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border)">Gelb</td><td style="padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--c-border);color:var(--c-warning)">Leberprobleme</td></tr>
|
||||||
|
|
@ -239,6 +239,7 @@ window.Page_erste_hilfe = (() => {
|
||||||
`;
|
`;
|
||||||
_bindTabs();
|
_bindTabs();
|
||||||
_bindAccordions();
|
_bindAccordions();
|
||||||
|
_bindNoteButtons();
|
||||||
_activateTab('lebensgefahr');
|
_activateTab('lebensgefahr');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,6 +341,10 @@ window.Page_erste_hilfe = (() => {
|
||||||
${massnahmenHtml}
|
${massnahmenHtml}
|
||||||
${warnHtml}
|
${warnHtml}
|
||||||
${e.extra || ''}
|
${e.extra || ''}
|
||||||
|
<div style="margin-top:var(--space-3);text-align:right">
|
||||||
|
<button class="btn btn-ghost btn-xs eh-note-btn" style="font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 8px"
|
||||||
|
data-kat-id="${katId}" data-titel="${e.titel.replace(/"/g,'"')}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -382,6 +387,102 @@ window.Page_erste_hilfe = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _bindNoteButtons() {
|
||||||
|
_container.querySelectorAll('.eh-note-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const katId = btn.dataset.katId;
|
||||||
|
const titel = btn.dataset.titel;
|
||||||
|
const kat = KATEGORIEN.find(k => k.id === katId);
|
||||||
|
const label = kat ? `${kat.label} — ${titel}` : titel;
|
||||||
|
_openNoteModal('erste_hilfe', katId, label, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// NOTIZ-MODAL (custom DOM, kein UI.modal um Konflikte zu vermeiden)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
|
||||||
|
document.getElementById('by-note-modal')?.remove();
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'by-note-modal';
|
||||||
|
overlay.style.cssText = 'position:fixed;inset:0;z-index:2000;background:rgba(0,0,0,.55);display:flex;align-items:flex-end;justify-content:center';
|
||||||
|
|
||||||
|
const _esc = s => s ? String(s).replace(/&/g,'&').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
|
// PUBLIC
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,14 @@ window.Page_events = (() => {
|
||||||
</a>
|
</a>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">${_icon('pencil-simple')}</button>` : ''}
|
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
|
||||||
|
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten" onclick="event.stopPropagation()">${_icon('pencil-simple')}</button>` : ''}
|
||||||
|
${_state.user ? `<button class="btn-icon ev-note-btn" data-ev-note-id="${ev.id}"
|
||||||
|
data-ev-note-label="${UI.escape(ev.titel + ' ' + ev.datum)}"
|
||||||
|
data-ev-note-ort="${UI.escape(ev.ort_name || '')}"
|
||||||
|
title="Notiz" style="color:var(--c-text-muted)" onclick="event.stopPropagation()">
|
||||||
|
${_icon('note-pencil')}</button>` : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
@ -268,7 +275,7 @@ window.Page_events = (() => {
|
||||||
const popup = `
|
const popup = `
|
||||||
<div style="min-width:180px">
|
<div style="min-width:180px">
|
||||||
<strong>${UI.escape(ev.titel)}</strong><br>
|
<strong>${UI.escape(ev.titel)}</strong><br>
|
||||||
<span style="color:#666;font-size:12px">${datum}</span><br>
|
<span style="color:var(--c-text-muted);font-size:12px">${datum}</span><br>
|
||||||
${ev.ort_name ? `<span style="font-size:12px">📍 ${UI.escape(ev.ort_name)}</span><br>` : ''}
|
${ev.ort_name ? `<span style="font-size:12px">📍 ${UI.escape(ev.ort_name)}</span><br>` : ''}
|
||||||
${ev.beschreibung ? `<span style="font-size:12px">${UI.escape(ev.beschreibung.slice(0, 80))}${ev.beschreibung.length > 80 ? '…' : ''}</span><br>` : ''}
|
${ev.beschreibung ? `<span style="font-size:12px">${UI.escape(ev.beschreibung.slice(0, 80))}${ev.beschreibung.length > 80 ? '…' : ''}</span><br>` : ''}
|
||||||
<a href="#" onclick="event.preventDefault();Page_events._openDetail(${ev.id})"
|
<a href="#" onclick="event.preventDefault();Page_events._openDetail(${ev.id})"
|
||||||
|
|
@ -634,11 +641,77 @@ window.Page_events = (() => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notiz-Button
|
||||||
|
const noteBtn = e.target.closest('.ev-note-btn');
|
||||||
|
if (noteBtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
_openNoteModal(
|
||||||
|
'event',
|
||||||
|
parseInt(noteBtn.dataset.evNoteId),
|
||||||
|
noteBtn.dataset.evNoteLabel,
|
||||||
|
noteBtn.dataset.evNoteOrt || null
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Karten-Klick → Detail
|
// Karten-Klick → Detail
|
||||||
const card = e.target.closest('[data-ev-id]');
|
const card = e.target.closest('[data-ev-id]');
|
||||||
if (card) { _showDetail(parseInt(card.dataset.evId)); }
|
if (card) { _showDetail(parseInt(card.dataset.evId)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
|
||||||
|
let existingNote = null;
|
||||||
|
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
|
||||||
|
|
||||||
|
const ovl = document.createElement('div');
|
||||||
|
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
|
||||||
|
ovl.innerHTML = `
|
||||||
|
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
|
||||||
|
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
|
||||||
|
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz — ${UI.escape(parentLabel)}</span>
|
||||||
|
<button id="ev-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="ev-note-text" rows="5"
|
||||||
|
style="width:100%;box-sizing:border-box;padding:var(--space-3);
|
||||||
|
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||||||
|
font-size:var(--text-sm);font-family:inherit;
|
||||||
|
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
|
||||||
|
placeholder="Deine Notiz zu diesem Event…">${UI.escape(existingNote?.text || '')}</textarea>
|
||||||
|
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
|
||||||
|
<button id="ev-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
|
||||||
|
<button id="ev-note-save" class="btn btn-primary flex-1">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(ovl);
|
||||||
|
|
||||||
|
const close = () => ovl.remove();
|
||||||
|
ovl.querySelector('#ev-note-close')?.addEventListener('click', close);
|
||||||
|
ovl.querySelector('#ev-note-cancel')?.addEventListener('click', close);
|
||||||
|
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
|
||||||
|
|
||||||
|
ovl.querySelector('#ev-note-save')?.addEventListener('click', async () => {
|
||||||
|
const text = ovl.querySelector('#ev-note-text')?.value?.trim() || '';
|
||||||
|
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
|
||||||
|
try {
|
||||||
|
if (existingNote?.id) {
|
||||||
|
await API.notes.update(existingNote.id, payload);
|
||||||
|
} else {
|
||||||
|
await API.notes.create(parentType, String(parentId), payload);
|
||||||
|
}
|
||||||
|
UI.toast.success('Notiz gespeichert.');
|
||||||
|
close();
|
||||||
|
} catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { init, refresh, openNew, _openDetail: _showDetail };
|
return { init, refresh, openNew, _openDetail: _showDetail };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ window.Page_friends = (() => {
|
||||||
<div style="width:36px;height:36px;border-radius:50%;flex-shrink:0;
|
<div style="width:36px;height:36px;border-radius:50%;flex-shrink:0;
|
||||||
background:var(--c-primary-subtle);
|
background:var(--c-primary-subtle);
|
||||||
display:flex;align-items:center;justify-content:center">
|
display:flex;align-items:center;justify-content:center">
|
||||||
<svg style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
|
<svg style="fill:currentColor;width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
|
||||||
<use href="/icons/phosphor.svg#link"></use>
|
<use href="/icons/phosphor.svg#link"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,17 @@ window.Page_health = (() => {
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// LIFECYCLE
|
// LIFECYCLE
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function init(container, appState) {
|
async function init(container, appState, params) {
|
||||||
_container = container;
|
_container = container;
|
||||||
_appState = appState;
|
_appState = appState;
|
||||||
|
if (params?.tab) {
|
||||||
|
const valid = _getTabs().some(t => t.key === params.tab);
|
||||||
|
if (valid) _activeTab = params.tab;
|
||||||
|
}
|
||||||
await _render();
|
await _render();
|
||||||
|
if (params?.openForm) {
|
||||||
|
setTimeout(() => _showForm(null, _activeTab), 200);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
|
|
@ -400,6 +407,10 @@ window.Page_health = (() => {
|
||||||
Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
|
Nächste Impfung: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon}
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||||||
|
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||||||
|
data-action="open-note" data-entry-id="${e.id}"
|
||||||
|
data-label="${_esc(e.bezeichnung)}"
|
||||||
|
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -445,6 +456,10 @@ window.Page_health = (() => {
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
${e.diagnose ? `<div class="health-card-note"><b>Diagnose:</b> ${_esc(e.diagnose)}</div>` : ''}
|
${e.diagnose ? `<div class="health-card-note"><b>Diagnose:</b> ${_esc(e.diagnose)}</div>` : ''}
|
||||||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||||||
|
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||||||
|
data-action="open-note" data-entry-id="${e.id}"
|
||||||
|
data-label="${_esc(e.bezeichnung)}"
|
||||||
|
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -493,6 +508,10 @@ window.Page_health = (() => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
${e.notiz ? `<div class="health-card-note" style="padding-top:var(--space-1)">${_esc(e.notiz)}</div>` : ''}
|
${e.notiz ? `<div class="health-card-note" style="padding-top:var(--space-1)">${_esc(e.notiz)}</div>` : ''}
|
||||||
|
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-1);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||||||
|
data-action="open-note" data-entry-id="${e.id}"
|
||||||
|
data-label="Gewicht ${_esc(e.datum)}"
|
||||||
|
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
|
|
@ -726,6 +745,10 @@ window.Page_health = (() => {
|
||||||
${interval ? ` · Abstand zur Vorherigen: ${interval} Tage` : ''}
|
${interval ? ` · Abstand zur Vorherigen: ${interval} Tage` : ''}
|
||||||
</div>
|
</div>
|
||||||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||||||
|
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||||||
|
data-action="open-note" data-entry-id="${e.id}"
|
||||||
|
data-label="Läufigkeit ${_esc(e.datum)}"
|
||||||
|
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
@ -760,6 +783,10 @@ window.Page_health = (() => {
|
||||||
${e.bis_datum ? ` · bis ${UI.time.format(e.bis_datum + 'T00:00:00')}` : ''}
|
${e.bis_datum ? ` · bis ${UI.time.format(e.bis_datum + 'T00:00:00')}` : ''}
|
||||||
</div>
|
</div>
|
||||||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||||||
|
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||||||
|
data-action="open-note" data-entry-id="${e.id}"
|
||||||
|
data-label="${_esc(e.bezeichnung)}"
|
||||||
|
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
|
|
@ -797,6 +824,10 @@ window.Page_health = (() => {
|
||||||
</div>
|
</div>
|
||||||
${e.reaktion ? `<div class="health-card-note"><b>Reaktion:</b> ${_esc(e.reaktion)}</div>` : ''}
|
${e.reaktion ? `<div class="health-card-note"><b>Reaktion:</b> ${_esc(e.reaktion)}</div>` : ''}
|
||||||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||||||
|
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||||||
|
data-action="open-note" data-entry-id="${e.id}"
|
||||||
|
data-label="${_esc(e.bezeichnung)}"
|
||||||
|
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
@ -837,6 +868,10 @@ window.Page_health = (() => {
|
||||||
${count > 1 ? ` · ${count} Dateien` : ''}
|
${count > 1 ? ` · ${count} Dateien` : ''}
|
||||||
</div>
|
</div>
|
||||||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||||||
|
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
|
||||||
|
data-action="open-note" data-entry-id="${e.id}"
|
||||||
|
data-label="${_esc(e.bezeichnung)}"
|
||||||
|
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||||
${count
|
${count
|
||||||
? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);align-items:center;flex-wrap:wrap">
|
? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);align-items:center;flex-wrap:wrap">
|
||||||
${mediaList.slice(0, 3).map(m => m.media_type === 'pdf'
|
${mediaList.slice(0, 3).map(m => m.media_type === 'pdf'
|
||||||
|
|
@ -874,6 +909,14 @@ window.Page_health = (() => {
|
||||||
const entry = (_data[_activeTab] || []).find(e => e.id === id);
|
const entry = (_data[_activeTab] || []).find(e => e.id === id);
|
||||||
if (entry) card.addEventListener('click', () => _openDetail(entry));
|
if (entry) card.addEventListener('click', () => _openDetail(entry));
|
||||||
});
|
});
|
||||||
|
content.querySelectorAll('[data-action="open-note"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = parseInt(btn.dataset.entryId);
|
||||||
|
const label = btn.dataset.label || '';
|
||||||
|
_openNoteModal('health', id, label, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
// Praxis öffnen
|
// Praxis öffnen
|
||||||
content.querySelectorAll('[data-action="open-praxis"]').forEach(el => {
|
content.querySelectorAll('[data-action="open-praxis"]').forEach(el => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
|
|
@ -1166,6 +1209,9 @@ window.Page_health = (() => {
|
||||||
if (!_data[t]) _data[t] = [];
|
if (!_data[t]) _data[t] = [];
|
||||||
_data[t].unshift(saved);
|
_data[t].unshift(saved);
|
||||||
UI.toast.success('Eintrag erstellt.');
|
UI.toast.success('Eintrag erstellt.');
|
||||||
|
if (t === 'gewicht' && saved.wert) {
|
||||||
|
_appState.activeDog.gewicht_kg = saved.wert;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-File-Upload
|
// Multi-File-Upload
|
||||||
|
|
@ -1830,6 +1876,89 @@ window.Page_health = (() => {
|
||||||
.replace(/"/g, '"');
|
.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 };
|
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);
|
<div style="width:36px;height:36px;border-radius:var(--radius-md);
|
||||||
background:var(--c-primary-subtle);flex-shrink:0;
|
background:var(--c-primary-subtle);flex-shrink:0;
|
||||||
display:flex;align-items:center;justify-content:center">
|
display:flex;align-items:center;justify-content:center">
|
||||||
<svg style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
|
<svg style="fill:currentColor;width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
|
||||||
<use href="/icons/phosphor.svg#${icon}"></use>
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -167,7 +167,7 @@ window.Page_onboarding = (() => {
|
||||||
<div style="width:64px;height:64px;border-radius:50%;
|
<div style="width:64px;height:64px;border-radius:50%;
|
||||||
background:var(--c-primary-subtle);margin:0 auto var(--space-4);
|
background:var(--c-primary-subtle);margin:0 auto var(--space-4);
|
||||||
display:flex;align-items:center;justify-content:center">
|
display:flex;align-items:center;justify-content:center">
|
||||||
<svg style="width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
|
<svg style="fill:currentColor;width:32px;height:32px;color:var(--c-primary)" aria-hidden="true">
|
||||||
<use href="/icons/phosphor.svg#dog"></use>
|
<use href="/icons/phosphor.svg#dog"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -262,7 +262,7 @@ window.Page_onboarding = (() => {
|
||||||
<div style="width:80px;height:80px;border-radius:50%;
|
<div style="width:80px;height:80px;border-radius:50%;
|
||||||
background:var(--c-success-subtle,#dcfce7);margin:0 auto var(--space-4);
|
background:var(--c-success-subtle,#dcfce7);margin:0 auto var(--space-4);
|
||||||
display:flex;align-items:center;justify-content:center">
|
display:flex;align-items:center;justify-content:center">
|
||||||
<svg style="width:40px;height:40px;color:var(--c-success)" aria-hidden="true">
|
<svg style="fill:currentColor;width:40px;height:40px;color:var(--c-success)" aria-hidden="true">
|
||||||
<use href="/icons/phosphor.svg#check-circle"></use>
|
<use href="/icons/phosphor.svg#check-circle"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1805,6 +1805,7 @@ window.Page_routes = (() => {
|
||||||
${_actionBtn('rd-gpx', 'download-simple', 'GPX')}
|
${_actionBtn('rd-gpx', 'download-simple', 'GPX')}
|
||||||
${_actionBtn('rd-share', 'arrow-square-out', 'Teilen')}
|
${_actionBtn('rd-share', 'arrow-square-out', 'Teilen')}
|
||||||
${_actionBtn('rd-navi', 'map-pin', 'Navi')}
|
${_actionBtn('rd-navi', 'map-pin', 'Navi')}
|
||||||
|
${_appState.user ? _actionBtn('rd-note', 'note-pencil', 'Notiz') : ''}
|
||||||
</div>
|
</div>
|
||||||
${ownerRow}
|
${ownerRow}
|
||||||
<button type="button" class="btn btn-primary w-full" id="rd-close">Schließen</button>
|
<button type="button" class="btn btn-primary w-full" id="rd-close">Schließen</button>
|
||||||
|
|
@ -1920,6 +1921,12 @@ window.Page_routes = (() => {
|
||||||
} catch (err) { UI.toast.error(err.message); }
|
} catch (err) { UI.toast.error(err.message); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notiz-Button
|
||||||
|
document.getElementById('rd-note')?.addEventListener('click', () => {
|
||||||
|
const label = route.name || (route.distanz_km ? route.distanz_km.toFixed(1) + ' km' : 'Route');
|
||||||
|
_openNoteModal('route', route.id, label, null);
|
||||||
|
});
|
||||||
|
|
||||||
// Mini-Map
|
// Mini-Map
|
||||||
let _detailMap = null;
|
let _detailMap = null;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -2504,6 +2511,59 @@ window.Page_routes = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
|
||||||
|
let existingNote = null;
|
||||||
|
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
|
||||||
|
|
||||||
|
const ovl = document.createElement('div');
|
||||||
|
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
|
||||||
|
ovl.innerHTML = `
|
||||||
|
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
|
||||||
|
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
|
||||||
|
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz — ${UI.escape(parentLabel)}</span>
|
||||||
|
<button id="rk-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="rk-note-text" rows="5"
|
||||||
|
style="width:100%;box-sizing:border-box;padding:var(--space-3);
|
||||||
|
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||||||
|
font-size:var(--text-sm);font-family:inherit;
|
||||||
|
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
|
||||||
|
placeholder="Deine Notiz zu dieser Route…">${UI.escape(existingNote?.text || '')}</textarea>
|
||||||
|
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
|
||||||
|
<button id="rk-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
|
||||||
|
<button id="rk-note-save" class="btn btn-primary flex-1">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(ovl);
|
||||||
|
|
||||||
|
const close = () => ovl.remove();
|
||||||
|
ovl.querySelector('#rk-note-close')?.addEventListener('click', close);
|
||||||
|
ovl.querySelector('#rk-note-cancel')?.addEventListener('click', close);
|
||||||
|
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
|
||||||
|
|
||||||
|
ovl.querySelector('#rk-note-save')?.addEventListener('click', async () => {
|
||||||
|
const text = ovl.querySelector('#rk-note-text')?.value?.trim() || '';
|
||||||
|
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
|
||||||
|
try {
|
||||||
|
if (existingNote?.id) {
|
||||||
|
await API.notes.update(existingNote.id, payload);
|
||||||
|
} else {
|
||||||
|
await API.notes.create(parentType, String(parentId), payload);
|
||||||
|
}
|
||||||
|
UI.toast.success('Notiz gespeichert.');
|
||||||
|
close();
|
||||||
|
} catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { init, refresh, onDogChange };
|
return { init, refresh, onDogChange };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -266,6 +266,30 @@ window.Page_settings = (() => {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- KI-Notiz-Assistent -->
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4)">
|
||||||
|
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#brain"></use></svg>
|
||||||
|
<div style="flex:1">
|
||||||
|
<div style="font-weight:500">KI-Notiz-Assistent</div>
|
||||||
|
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
|
||||||
|
Erkennt Muster in deinen Notizen und macht Vorschläge
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label style="position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0">
|
||||||
|
<input type="checkbox" id="toggle-notes-ki"
|
||||||
|
style="opacity:0;width:0;height:0;position:absolute"
|
||||||
|
${u.notes_ki_enabled ? 'checked' : ''}>
|
||||||
|
<span style="position:absolute;cursor:pointer;inset:0;border-radius:12px;
|
||||||
|
background:var(--c-border);transition:.2s"
|
||||||
|
id="toggle-notes-ki-track"></span>
|
||||||
|
<span id="toggle-notes-ki-thumb"
|
||||||
|
style="position:absolute;top:2px;left:${u.notes_ki_enabled ? '22px' : '2px'};
|
||||||
|
width:20px;height:20px;border-radius:50%;
|
||||||
|
background:#fff;transition:.2s;
|
||||||
|
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -635,6 +659,25 @@ window.Page_settings = (() => {
|
||||||
: 'Pocket-Modus deaktiviert.');
|
: 'Pocket-Modus deaktiviert.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('toggle-notes-ki')?.addEventListener('change', async e => {
|
||||||
|
const enabled = e.target.checked;
|
||||||
|
const track = document.getElementById('toggle-notes-ki-track');
|
||||||
|
const thumb = document.getElementById('toggle-notes-ki-thumb');
|
||||||
|
if (track) track.style.background = enabled ? 'var(--c-primary)' : 'var(--c-border)';
|
||||||
|
if (thumb) thumb.style.left = enabled ? '22px' : '2px';
|
||||||
|
try {
|
||||||
|
await API.patch('/profile', { notes_ki_enabled: enabled ? 1 : 0 });
|
||||||
|
_appState.user.notes_ki_enabled = enabled ? 1 : 0;
|
||||||
|
UI.toast.success(enabled ? 'KI-Notiz-Assistent aktiviert.' : 'KI-Notiz-Assistent deaktiviert.');
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.');
|
||||||
|
// Revert UI
|
||||||
|
e.target.checked = !enabled;
|
||||||
|
if (track) track.style.background = !enabled ? 'var(--c-primary)' : 'var(--c-border)';
|
||||||
|
if (thumb) thumb.style.left = !enabled ? '22px' : '2px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
_loadReferral();
|
_loadReferral();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,12 @@ window.Page_sitting = (() => {
|
||||||
<div class="sitting-card-side">
|
<div class="sitting-card-side">
|
||||||
<div class="sitting-price">${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €/Tag' : 'Preis anfragen'}</div>
|
<div class="sitting-price">${s.preis_pro_tag > 0 ? s.preis_pro_tag.toFixed(0) + ' €/Tag' : 'Preis anfragen'}</div>
|
||||||
<div class="sitting-dogs">max. ${s.max_hunde} Hund${s.max_hunde !== 1 ? 'e' : ''}</div>
|
<div class="sitting-dogs">max. ${s.max_hunde} Hund${s.max_hunde !== 1 ? 'e' : ''}</div>
|
||||||
|
${_state.user ? `<button class="btn-icon sit-note-btn"
|
||||||
|
data-sit-note-id="${s.id}"
|
||||||
|
data-sit-note-label="${UI.escHtml(s.sitter_name + ' ' + (s.datum || ''))}"
|
||||||
|
title="Notiz" style="color:var(--c-text-muted);margin-top:var(--space-1)"
|
||||||
|
onclick="event.stopPropagation()">
|
||||||
|
${UI.icon('note-pencil')}</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -704,6 +710,19 @@ window.Page_sitting = (() => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notiz-Button auf Sitter-Karte
|
||||||
|
const noteBtn = e.target.closest('.sit-note-btn');
|
||||||
|
if (noteBtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
_openNoteModal(
|
||||||
|
'sitting',
|
||||||
|
parseInt(noteBtn.dataset.sitNoteId),
|
||||||
|
noteBtn.dataset.sitNoteLabel,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Sitter-Karte
|
// Sitter-Karte
|
||||||
const sitterCard = e.target.closest('[data-sit-id]');
|
const sitterCard = e.target.closest('[data-sit-id]');
|
||||||
if (sitterCard && !e.target.closest('button')) {
|
if (sitterCard && !e.target.closest('button')) {
|
||||||
|
|
@ -741,6 +760,59 @@ window.Page_sitting = (() => {
|
||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
|
||||||
|
let existingNote = null;
|
||||||
|
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
|
||||||
|
|
||||||
|
const ovl = document.createElement('div');
|
||||||
|
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
|
||||||
|
ovl.innerHTML = `
|
||||||
|
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
|
||||||
|
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
|
||||||
|
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz — ${UI.escape(parentLabel)}</span>
|
||||||
|
<button id="sit-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="sit-note-text" rows="5"
|
||||||
|
style="width:100%;box-sizing:border-box;padding:var(--space-3);
|
||||||
|
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||||||
|
font-size:var(--text-sm);font-family:inherit;
|
||||||
|
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
|
||||||
|
placeholder="Deine Notiz zu diesem Sitter…">${UI.escape(existingNote?.text || '')}</textarea>
|
||||||
|
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
|
||||||
|
<button id="sit-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
|
||||||
|
<button id="sit-note-save" class="btn btn-primary flex-1">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(ovl);
|
||||||
|
|
||||||
|
const close = () => ovl.remove();
|
||||||
|
ovl.querySelector('#sit-note-close')?.addEventListener('click', close);
|
||||||
|
ovl.querySelector('#sit-note-cancel')?.addEventListener('click', close);
|
||||||
|
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
|
||||||
|
|
||||||
|
ovl.querySelector('#sit-note-save')?.addEventListener('click', async () => {
|
||||||
|
const text = ovl.querySelector('#sit-note-text')?.value?.trim() || '';
|
||||||
|
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
|
||||||
|
try {
|
||||||
|
if (existingNote?.id) {
|
||||||
|
await API.notes.update(existingNote.id, payload);
|
||||||
|
} else {
|
||||||
|
await API.notes.create(parentType, String(parentId), payload);
|
||||||
|
}
|
||||||
|
UI.toast.success('Notiz gespeichert.');
|
||||||
|
close();
|
||||||
|
} catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { init, refresh };
|
return { init, refresh };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -895,6 +895,7 @@ window.Page_uebungen = (() => {
|
||||||
_bindAccordions();
|
_bindAccordions();
|
||||||
_bindStatusButtons();
|
_bindStatusButtons();
|
||||||
_bindLogButtons();
|
_bindLogButtons();
|
||||||
|
_bindNotizButtons();
|
||||||
if (_activeTab === 'ki-trainer') _loadKiTrainerFeedback();
|
if (_activeTab === 'ki-trainer') _loadKiTrainerFeedback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -965,6 +966,19 @@ window.Page_uebungen = (() => {
|
||||||
Einheit
|
Einheit
|
||||||
</button>
|
</button>
|
||||||
${_sessionStatsChip(_activeTab, u.name)}
|
${_sessionStatsChip(_activeTab, u.name)}
|
||||||
|
<button class="ueb-notiz-btn"
|
||||||
|
data-tab="${_esc(_activeTab)}"
|
||||||
|
data-name="${_esc(u.name)}"
|
||||||
|
title="Notiz hinzufügen"
|
||||||
|
style="background:none;border:1px solid var(--c-border);cursor:pointer;
|
||||||
|
padding:3px 7px;border-radius:var(--radius-sm);
|
||||||
|
display:flex;align-items:center;gap:3px;
|
||||||
|
font-size:var(--text-xs);color:var(--c-text-secondary)">
|
||||||
|
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0" aria-hidden="true">
|
||||||
|
<use href="/icons/phosphor.svg#note-pencil"></use>
|
||||||
|
</svg>
|
||||||
|
Notiz
|
||||||
|
</button>
|
||||||
<button class="ueb-status-btn"
|
<button class="ueb-status-btn"
|
||||||
data-tab="${_esc(_activeTab)}"
|
data-tab="${_esc(_activeTab)}"
|
||||||
data-name="${_esc(u.name)}"
|
data-name="${_esc(u.name)}"
|
||||||
|
|
@ -1006,7 +1020,7 @@ window.Page_uebungen = (() => {
|
||||||
background:#78350f22;border:1px solid #d9770644;border-radius:var(--radius-sm);
|
background:#78350f22;border:1px solid #d9770644;border-radius:var(--radius-sm);
|
||||||
font-size:var(--text-xs);color:var(--c-text);line-height:1.4;
|
font-size:var(--text-xs);color:var(--c-text);line-height:1.4;
|
||||||
display:flex;align-items:flex-start;gap:var(--space-2)">
|
display:flex;align-items:flex-start;gap:var(--space-2)">
|
||||||
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0;margin-top:1px;color:#f59e0b" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
|
<svg class="ph-icon" style="width:13px;height:13px;flex-shrink:0;margin-top:1px;color:var(--c-warning)" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
|
||||||
<span>${_esc(u.hinweis)}</span>
|
<span>${_esc(u.hinweis)}</span>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
@ -1039,7 +1053,7 @@ window.Page_uebungen = (() => {
|
||||||
${u.fehler.length ? `
|
${u.fehler.length ? `
|
||||||
<p style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
|
<p style="font-size:var(--text-xs);font-weight:var(--weight-semibold);
|
||||||
color:var(--c-text-secondary);margin-bottom:var(--space-2);text-transform:uppercase;letter-spacing:0.05em">
|
color:var(--c-text-secondary);margin-bottom:var(--space-2);text-transform:uppercase;letter-spacing:0.05em">
|
||||||
<svg class="ph-icon" style="width:12px;height:12px;color:#f59e0b" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
|
<svg class="ph-icon" style="width:12px;height:12px;color:var(--c-warning)" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>
|
||||||
Häufige Fehler
|
Häufige Fehler
|
||||||
</p>
|
</p>
|
||||||
<ul style="margin:0 0 var(--space-4);padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-2)">
|
<ul style="margin:0 0 var(--space-4);padding-left:var(--space-5);display:flex;flex-direction:column;gap:var(--space-2)">
|
||||||
|
|
@ -1100,6 +1114,252 @@ window.Page_uebungen = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _bindNotizButtons() {
|
||||||
|
_container.querySelectorAll('.ueb-notiz-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const exerciseId = `${btn.dataset.tab}_${btn.dataset.name.replace(/[\s/]+/g, '_')}`;
|
||||||
|
_openNotizModal(exerciseId, btn.dataset.name, btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _openNotizModal(exerciseId, exerciseName, triggerBtn) {
|
||||||
|
const modalId = 'ueb-notiz-modal';
|
||||||
|
document.getElementById(modalId)?.remove();
|
||||||
|
|
||||||
|
// Lade bestehende Notiz
|
||||||
|
let existingNote = null;
|
||||||
|
if (_appState?.user) {
|
||||||
|
try {
|
||||||
|
const notes = await API.notes.get('training_session', exerciseId.length > 0 ? exerciseId : 0);
|
||||||
|
if (notes && notes.length > 0) existingNote = notes[0];
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = modalId;
|
||||||
|
overlay.style.cssText = `
|
||||||
|
position:fixed;inset:0;z-index:9999;
|
||||||
|
display:flex;align-items:flex-end;justify-content:center;
|
||||||
|
background:rgba(0,0,0,0.45);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const noteText = existingNote?.text || '';
|
||||||
|
const meta = existingNote?.meta_json || {};
|
||||||
|
const currentErfolgsquote = meta.erfolgsquote || null;
|
||||||
|
const currentUmgebung = meta.umgebung || null;
|
||||||
|
const currentStimmung = meta.hund_stimmung || null;
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div style="background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
width:100%;max-width:480px;max-height:85vh;overflow-y:auto;
|
||||||
|
padding:var(--space-5) var(--space-4) var(--space-6);box-shadow:0 -4px 24px rgba(0,0,0,0.15)">
|
||||||
|
<div style="width:40px;height:4px;background:var(--c-border);border-radius:2px;margin:0 auto var(--space-4)"></div>
|
||||||
|
|
||||||
|
<h3 style="font-size:var(--text-base);font-weight:var(--weight-semibold);color:var(--c-text);
|
||||||
|
margin:0 0 var(--space-4);text-align:center">
|
||||||
|
Notiz: ${_esc(exerciseName)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
|
||||||
|
|
||||||
|
<!-- Freitext -->
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||||
|
color:var(--c-text);margin-bottom:var(--space-2)">Notiz</label>
|
||||||
|
<textarea id="ueb-notiz-text" rows="3"
|
||||||
|
placeholder="Was ist dir aufgefallen? Tipps für nächstes Mal…"
|
||||||
|
style="width:100%;padding:var(--space-3);border:1.5px solid var(--c-border);
|
||||||
|
border-radius:var(--radius-md);font-size:var(--text-sm);
|
||||||
|
font-family:var(--font-sans);background:var(--c-surface);
|
||||||
|
color:var(--c-text);resize:vertical;outline:none;
|
||||||
|
line-height:1.5">${_esc(noteText)}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erfolgsquote -->
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||||
|
color:var(--c-text);margin-bottom:var(--space-2)">Bewertung (optional)</label>
|
||||||
|
<div style="display:flex;gap:var(--space-2)">
|
||||||
|
${[1,2,3,4,5].map(n => `
|
||||||
|
<button type="button" class="ueb-notiz-pfote" data-val="${n}"
|
||||||
|
style="font-size:1.4rem;border:1.5px solid var(--c-border);
|
||||||
|
border-radius:var(--radius-md);padding:4px 10px;cursor:pointer;
|
||||||
|
background:${currentErfolgsquote === n ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'};
|
||||||
|
border-color:${currentErfolgsquote === n ? 'var(--c-primary)' : 'var(--c-border)'};
|
||||||
|
transition:all 0.15s">🐾</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Umgebung -->
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||||
|
color:var(--c-text);margin-bottom:var(--space-2)">Umgebung (optional)</label>
|
||||||
|
<div style="display:flex;gap:var(--space-2)">
|
||||||
|
${[['🏠','zuhause'],['🌿','natur'],['🌆','stadt']].map(([emoji, val]) => `
|
||||||
|
<button type="button" class="ueb-notiz-umgebung" data-val="${val}"
|
||||||
|
style="font-size:1.2rem;border:1.5px solid var(--c-border);
|
||||||
|
border-radius:var(--radius-md);padding:4px 12px;cursor:pointer;
|
||||||
|
background:${currentUmgebung === val ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'};
|
||||||
|
border-color:${currentUmgebung === val ? 'var(--c-primary)' : 'var(--c-border)'};
|
||||||
|
transition:all 0.15s">${emoji}</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hund-Stimmung -->
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||||
|
color:var(--c-text);margin-bottom:var(--space-2)">Stimmung des Hundes (optional)</label>
|
||||||
|
<div style="display:flex;gap:var(--space-2)">
|
||||||
|
${[['😊','super'],['😐','ok'],['😔','mude']].map(([emoji, val]) => `
|
||||||
|
<button type="button" class="ueb-notiz-stimmung" data-val="${val}"
|
||||||
|
style="font-size:1.2rem;border:1.5px solid var(--c-border);
|
||||||
|
border-radius:var(--radius-md);padding:4px 12px;cursor:pointer;
|
||||||
|
background:${currentStimmung === val ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)'};
|
||||||
|
border-color:${currentStimmung === val ? 'var(--c-primary)' : 'var(--c-border)'};
|
||||||
|
transition:all 0.15s">${emoji}</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<div style="display:flex;gap:var(--space-3);margin-top:var(--space-5)">
|
||||||
|
${existingNote ? `
|
||||||
|
<button id="ueb-notiz-delete" type="button"
|
||||||
|
style="padding:var(--space-3) var(--space-4);border-radius:var(--radius-md);
|
||||||
|
border:1.5px solid var(--c-danger);background:none;
|
||||||
|
color:var(--c-danger);font-size:var(--text-sm);cursor:pointer">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<button id="ueb-notiz-cancel" type="button"
|
||||||
|
style="flex:1;padding:var(--space-3);border-radius:var(--radius-md);
|
||||||
|
border:1.5px solid var(--c-border);background:none;
|
||||||
|
color:var(--c-text-secondary);font-size:var(--text-sm);cursor:pointer">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button id="ueb-notiz-save" type="button"
|
||||||
|
style="flex:2;padding:var(--space-3);border-radius:var(--radius-md);
|
||||||
|
border:none;background:var(--c-primary);
|
||||||
|
color:#fff;font-size:var(--text-sm);font-weight:var(--weight-semibold);cursor:pointer">
|
||||||
|
${existingNote ? 'Aktualisieren' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// State
|
||||||
|
let selectedErfolgsquote = currentErfolgsquote;
|
||||||
|
let selectedUmgebung = currentUmgebung;
|
||||||
|
let selectedStimmung = currentStimmung;
|
||||||
|
|
||||||
|
// Pfoten-Buttons
|
||||||
|
overlay.querySelectorAll('.ueb-notiz-pfote').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const val = parseInt(btn.dataset.val, 10);
|
||||||
|
selectedErfolgsquote = selectedErfolgsquote === val ? null : val;
|
||||||
|
overlay.querySelectorAll('.ueb-notiz-pfote').forEach(b => {
|
||||||
|
const active = parseInt(b.dataset.val, 10) === selectedErfolgsquote;
|
||||||
|
b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)';
|
||||||
|
b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Umgebung-Buttons
|
||||||
|
overlay.querySelectorAll('.ueb-notiz-umgebung').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
selectedUmgebung = selectedUmgebung === btn.dataset.val ? null : btn.dataset.val;
|
||||||
|
overlay.querySelectorAll('.ueb-notiz-umgebung').forEach(b => {
|
||||||
|
const active = b.dataset.val === selectedUmgebung;
|
||||||
|
b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)';
|
||||||
|
b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stimmung-Buttons
|
||||||
|
overlay.querySelectorAll('.ueb-notiz-stimmung').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
selectedStimmung = selectedStimmung === btn.dataset.val ? null : btn.dataset.val;
|
||||||
|
overlay.querySelectorAll('.ueb-notiz-stimmung').forEach(b => {
|
||||||
|
const active = b.dataset.val === selectedStimmung;
|
||||||
|
b.style.background = active ? 'var(--c-primary-subtle)' : 'var(--c-surface-2)';
|
||||||
|
b.style.borderColor = active ? 'var(--c-primary)' : 'var(--c-border)';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function _closeNotizModal() {
|
||||||
|
overlay.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.addEventListener('click', e => { if (e.target === overlay) _closeNotizModal(); });
|
||||||
|
overlay.querySelector('#ueb-notiz-cancel').addEventListener('click', _closeNotizModal);
|
||||||
|
|
||||||
|
// Speichern
|
||||||
|
overlay.querySelector('#ueb-notiz-save').addEventListener('click', async () => {
|
||||||
|
const text = overlay.querySelector('#ueb-notiz-text').value.trim();
|
||||||
|
if (!text) { UI.toast.warning('Bitte gib eine Notiz ein.'); return; }
|
||||||
|
|
||||||
|
const saveBtn = overlay.querySelector('#ueb-notiz-save');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Speichern…';
|
||||||
|
|
||||||
|
const meta = {};
|
||||||
|
if (selectedErfolgsquote) meta.erfolgsquote = selectedErfolgsquote;
|
||||||
|
if (selectedUmgebung) meta.umgebung = selectedUmgebung;
|
||||||
|
if (selectedStimmung) meta.hund_stimmung = selectedStimmung;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
text,
|
||||||
|
meta_json: Object.keys(meta).length > 0 ? meta : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existingNote) {
|
||||||
|
await API.notes.update(existingNote.id, payload);
|
||||||
|
} else {
|
||||||
|
await API.notes.create('training_session', exerciseId, payload);
|
||||||
|
}
|
||||||
|
_closeNotizModal();
|
||||||
|
UI.toast.success('Notiz gespeichert.');
|
||||||
|
// Notiz-Button leicht hervorheben
|
||||||
|
if (triggerBtn) {
|
||||||
|
triggerBtn.style.borderColor = 'var(--c-primary)';
|
||||||
|
triggerBtn.style.color = 'var(--c-primary)';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = existingNote ? 'Aktualisieren' : 'Speichern';
|
||||||
|
UI.toast.error('Speichern fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Löschen
|
||||||
|
overlay.querySelector('#ueb-notiz-delete')?.addEventListener('click', async () => {
|
||||||
|
if (!existingNote) return;
|
||||||
|
try {
|
||||||
|
await API.notes.delete(existingNote.id);
|
||||||
|
_closeNotizModal();
|
||||||
|
UI.toast.success('Notiz gelöscht.');
|
||||||
|
if (triggerBtn) {
|
||||||
|
triggerBtn.style.borderColor = '';
|
||||||
|
triggerBtn.style.color = '';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
UI.toast.error('Löschen fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function _openLogModal(tab, exerciseName, initialReps) {
|
function _openLogModal(tab, exerciseName, initialReps) {
|
||||||
// Build the modal HTML
|
// Build the modal HTML
|
||||||
const modalId = 'ueb-log-modal';
|
const modalId = 'ueb-log-modal';
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,18 @@ window.Page_walks = (() => {
|
||||||
el.querySelectorAll('.walks-card').forEach(card => {
|
el.querySelectorAll('.walks-card').forEach(card => {
|
||||||
card.addEventListener('click', () => _openDetail(parseInt(card.dataset.id)));
|
card.addEventListener('click', () => _openDetail(parseInt(card.dataset.id)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
el.querySelectorAll('.wk-note-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
_openNoteModal(
|
||||||
|
'walk',
|
||||||
|
parseInt(btn.dataset.wkNoteId),
|
||||||
|
btn.dataset.wkNoteLabel,
|
||||||
|
btn.dataset.wkNoteOrt || null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _walkCardHTML(w) {
|
function _walkCardHTML(w) {
|
||||||
|
|
@ -217,7 +229,16 @@ window.Page_walks = (() => {
|
||||||
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
|
${isOwn ? '<span class="walks-badge walks-badge--own">Mein Treffen</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="walks-card-arrow">›</div>
|
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
|
||||||
|
<div class="walks-card-arrow">›</div>
|
||||||
|
${_appState.user ? `<button class="btn-icon wk-note-btn"
|
||||||
|
data-wk-note-id="${w.id}"
|
||||||
|
data-wk-note-label="${UI.escape(w.titel + ' ' + w.datum)}"
|
||||||
|
data-wk-note-ort="${UI.escape(w.ort_name || '')}"
|
||||||
|
title="Notiz" style="color:var(--c-text-muted);font-size:var(--text-xs)"
|
||||||
|
onclick="event.stopPropagation()">
|
||||||
|
${UI.icon('note-pencil')}</button>` : ''}
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -964,6 +985,59 @@ window.Page_walks = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
async function _openNoteModal(parentType, parentId, parentLabel, locationName) {
|
||||||
|
let existingNote = null;
|
||||||
|
try { existingNote = await API.notes.get(parentType, String(parentId)); } catch {}
|
||||||
|
|
||||||
|
const ovl = document.createElement('div');
|
||||||
|
ovl.style.cssText = 'position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
|
||||||
|
ovl.innerHTML = `
|
||||||
|
<div style="width:100%;max-width:600px;background:var(--c-surface);border-radius:var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
padding:var(--space-4);box-sizing:border-box;max-height:80vh;display:flex;flex-direction:column">
|
||||||
|
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
|
||||||
|
<svg class="ph-icon" aria-hidden="true" style="color:var(--c-primary)"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
|
||||||
|
<span style="font-weight:600;flex:1"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz — ${UI.escape(parentLabel)}</span>
|
||||||
|
<button id="wk-note-close" style="background:none;border:none;cursor:pointer;color:var(--c-text-muted);padding:4px">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="wk-note-text" rows="5"
|
||||||
|
style="width:100%;box-sizing:border-box;padding:var(--space-3);
|
||||||
|
border:1.5px solid var(--c-border);border-radius:var(--radius-md);
|
||||||
|
font-size:var(--text-sm);font-family:inherit;
|
||||||
|
background:var(--c-bg);color:var(--c-text);resize:vertical;flex:1"
|
||||||
|
placeholder="Deine Notiz zu diesem Gassi-Treffen…">${UI.escape(existingNote?.text || '')}</textarea>
|
||||||
|
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
|
||||||
|
<button id="wk-note-cancel" class="btn btn-secondary flex-1">Abbrechen</button>
|
||||||
|
<button id="wk-note-save" class="btn btn-primary flex-1">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(ovl);
|
||||||
|
|
||||||
|
const close = () => ovl.remove();
|
||||||
|
ovl.querySelector('#wk-note-close')?.addEventListener('click', close);
|
||||||
|
ovl.querySelector('#wk-note-cancel')?.addEventListener('click', close);
|
||||||
|
ovl.addEventListener('click', e => { if (e.target === ovl) close(); });
|
||||||
|
|
||||||
|
ovl.querySelector('#wk-note-save')?.addEventListener('click', async () => {
|
||||||
|
const text = ovl.querySelector('#wk-note-text')?.value?.trim() || '';
|
||||||
|
const payload = { text, parent_label: parentLabel, location_name: locationName || null };
|
||||||
|
try {
|
||||||
|
if (existingNote?.id) {
|
||||||
|
await API.notes.update(existingNote.id, payload);
|
||||||
|
} else {
|
||||||
|
await API.notes.create(parentType, String(parentId), payload);
|
||||||
|
}
|
||||||
|
UI.toast.success('Notiz gespeichert.');
|
||||||
|
close();
|
||||||
|
} catch (err) { UI.toast.error(err.message || 'Fehler beim Speichern.'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { init, refresh, onDogChange, openNew, openDetail: _openDetail };
|
return { init, refresh, onDogChange, openNew, openDetail: _openDetail };
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ window.Page_welcome = (() => {
|
||||||
style="width:36px;height:36px;border-radius:var(--radius-md);
|
style="width:36px;height:36px;border-radius:var(--radius-md);
|
||||||
background:var(--c-primary);flex-shrink:0;
|
background:var(--c-primary);flex-shrink:0;
|
||||||
display:flex;align-items:center;justify-content:center">
|
display:flex;align-items:center;justify-content:center">
|
||||||
<svg style="width:20px;height:20px;color:#fff" aria-hidden="true">
|
<svg style="fill:currentColor;width:20px;height:20px;color:#fff" aria-hidden="true">
|
||||||
<use href="/icons/phosphor.svg#list"></use>
|
<use href="/icons/phosphor.svg#list"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -237,7 +237,7 @@ window.Page_welcome = (() => {
|
||||||
<div style="width:34px;height:34px;border-radius:var(--radius-md);
|
<div style="width:34px;height:34px;border-radius:var(--radius-md);
|
||||||
background:var(--c-primary-subtle);flex-shrink:0;
|
background:var(--c-primary-subtle);flex-shrink:0;
|
||||||
display:flex;align-items:center;justify-content:center">
|
display:flex;align-items:center;justify-content:center">
|
||||||
<svg style="width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
|
<svg style="fill:currentColor;width:18px;height:18px;color:var(--c-primary)" aria-hidden="true">
|
||||||
<use href="/icons/phosphor.svg#${icon}"></use>
|
<use href="/icons/phosphor.svg#${icon}"></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,15 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v370';
|
const CACHE_VERSION = 'by-v405';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
|
|
||||||
// index.html wird NICHT pre-gecacht (immer Network-First)
|
// index.html wird NICHT pre-gecacht (immer Network-First)
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
'/css/design-system.css',
|
'/css/design-system.css?v=382',
|
||||||
'/css/layout.css',
|
'/css/layout.css?v=382',
|
||||||
'/css/components.css',
|
'/css/components.css?v=382',
|
||||||
'/icons/phosphor.svg',
|
'/icons/phosphor.svg',
|
||||||
'/js/api.js',
|
'/js/api.js',
|
||||||
'/js/ui.js',
|
'/js/ui.js',
|
||||||
|
|
@ -82,8 +82,8 @@ self.addEventListener('fetch', event => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seiten-Module (/js/pages/…): immer Network-First (versioniert über ?v=, kein alter Cache-Treffer)
|
// CSS + Seiten-Module: immer Network-First — damit iOS nie veraltete CSS cached
|
||||||
if (url.pathname.startsWith('/js/pages/')) {
|
if (url.pathname.startsWith('/css/') || url.pathname.startsWith('/js/pages/')) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(event.request)
|
fetch(event.request)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
|
|
||||||
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