Feature: Tagebuch Ort/POI, Foto/Video-Edit, Modal-UX, iOS-Fixes
Tagebuch — Ort/POI (DayOne-ähnlich):
- diary.location_name Spalte, DiaryCreate/Update mit gps_lat/lon/location_name
- GET /api/dogs/{id}/diary/nearby: Overpass + Nominatim (vor {entry_id}-Route)
- Mini-Karte im Edit-Formular: Leaflet lazy, Edit-Modus, SVG-Pin
- Meilenstein-Toggle: Button statt Checkbox, Filter in Toolbar
- Datenmigration: 97 Ort-Einträge aus text → location_name
Tagebuch — Foto/Video:
- Foto/Video im Edit: Ersetzen + Löschen, DELETE media endpoint
- Media-Picker: Kamera/Mediathek/Datei Buttons
- Video-Wiedergabe (<video controls> in Detail + Edit)
Modal-UX (alle Edit-Karten vereinheitlicht):
- Footer-Pattern: [Speichern vollbreit] / [Löschen][Abbrechen]
- diary, dog-profile, events, health, places, walks, settings, sitting
- Löschen aus Detail-Modal → Edit-Form verschoben
iOS Mobile-Fixes:
- Auto-Zoom: input/select/textarea font-size 16px !important
- Scroll-Through: html.modal-open + touch-action:none auf Overlay
- Kein position:fixed mehr auf body (kein Scroll-Sprung)
PWA & Icons:
- icon-512-any.png + icon-192-any.png (quadratisch, maskable)
- manifest.json: purpose any/maskable getrennt
- Gesundheits-Icon: syringe → first-aid
Import-Fix:
- _HTMLStripper überspringt video/audio/script → kein "Video nicht gefunden" mehr
This commit is contained in:
parent
88912e2746
commit
f8d354749d
19 changed files with 963 additions and 198 deletions
|
|
@ -1,35 +1,40 @@
|
|||
"""BAN YARO — Tagebuch Routes"""
|
||||
|
||||
import os, uuid, json
|
||||
import os, uuid, json, math
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
import ki as KI
|
||||
import httpx
|
||||
|
||||
router = APIRouter()
|
||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||
|
||||
|
||||
class DiaryCreate(BaseModel):
|
||||
datum: Optional[str] = None # ISO date, default heute
|
||||
typ: str = "eintrag"
|
||||
titel: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
tags: Optional[list] = None
|
||||
gps_lat: Optional[float] = None
|
||||
gps_lon: Optional[float] = None
|
||||
is_milestone: bool = False
|
||||
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
|
||||
datum: Optional[str] = None # ISO date, default heute
|
||||
typ: str = "eintrag"
|
||||
titel: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
tags: Optional[list] = None
|
||||
gps_lat: Optional[float] = None
|
||||
gps_lon: Optional[float] = None
|
||||
location_name: Optional[str] = None
|
||||
is_milestone: bool = False
|
||||
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
|
||||
|
||||
|
||||
class DiaryUpdate(BaseModel):
|
||||
titel: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
tags: Optional[list] = None
|
||||
is_milestone: Optional[bool] = None
|
||||
dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen
|
||||
titel: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
tags: Optional[list] = None
|
||||
gps_lat: Optional[float] = None
|
||||
gps_lon: Optional[float] = None
|
||||
location_name: Optional[str] = None
|
||||
is_milestone: Optional[bool] = None
|
||||
dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen
|
||||
|
||||
|
||||
def _own_dog(dog_id: int, user_id: int, conn):
|
||||
|
|
@ -90,26 +95,28 @@ def _entry_dict(row, dog_ids_map: dict) -> dict:
|
|||
|
||||
@router.get("/{dog_id}/diary")
|
||||
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
|
||||
q: Optional[str] = None,
|
||||
q: Optional[str] = None, milestone: int = 0,
|
||||
user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
_own_dog(dog_id, user["id"], conn)
|
||||
extra = "AND (d.is_milestone=1 OR d.typ='meilenstein')" if milestone else ""
|
||||
if q:
|
||||
pattern = f"%{q}%"
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT d.* FROM diary d
|
||||
f"""SELECT DISTINCT d.* FROM diary d
|
||||
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
|
||||
WHERE (d.dog_id = ? OR dd.dog_id = ?)
|
||||
AND (d.titel LIKE ? OR d.text LIKE ? OR d.tags LIKE ?)
|
||||
{extra}
|
||||
ORDER BY d.datum DESC, d.created_at DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(dog_id, dog_id, pattern, pattern, pattern, limit, offset)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT d.* FROM diary d
|
||||
f"""SELECT DISTINCT d.* FROM diary d
|
||||
LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
|
||||
WHERE d.dog_id = ? OR dd.dog_id = ?
|
||||
WHERE (d.dog_id = ? OR dd.dog_id = ?) {extra}
|
||||
ORDER BY d.datum DESC, d.created_at DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(dog_id, dog_id, limit, offset)
|
||||
|
|
@ -139,12 +146,12 @@ async def create_diary(dog_id: int, data: DiaryCreate,
|
|||
|
||||
conn.execute(
|
||||
"""INSERT INTO diary
|
||||
(dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone)
|
||||
(dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, location_name, is_milestone)
|
||||
VALUES (?,
|
||||
COALESCE(?, date('now')),
|
||||
?,?,?,?,?,?,?)""",
|
||||
?,?,?,?,?,?,?,?)""",
|
||||
(dog_id, data.datum, data.typ, data.titel, data.text,
|
||||
json.dumps(tags), data.gps_lat, data.gps_lon, int(data.is_milestone))
|
||||
json.dumps(tags), data.gps_lat, data.gps_lon, data.location_name, int(data.is_milestone))
|
||||
)
|
||||
entry = conn.execute(
|
||||
"SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1",
|
||||
|
|
@ -156,6 +163,133 @@ async def create_diary(dog_id: int, data: DiaryCreate,
|
|||
return _entry_dict(entry, dogs_map)
|
||||
|
||||
|
||||
def _haversine_km(lat1, lon1, lat2, lon2) -> float:
|
||||
R = 6371
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = (math.sin(dlat / 2) ** 2
|
||||
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2))
|
||||
* math.sin(dlon / 2) ** 2)
|
||||
return R * 2 * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
@router.get("/{dog_id}/diary/nearby")
|
||||
async def nearby_places(dog_id: int, lat: float, lon: float,
|
||||
user=Depends(get_current_user)):
|
||||
results = []
|
||||
|
||||
# 1. User-eigene Places
|
||||
with db() as conn:
|
||||
_own_dog(dog_id, user["id"], conn)
|
||||
places = conn.execute(
|
||||
"SELECT name, typ, lat, lon FROM places WHERE user_id=? AND lat IS NOT NULL",
|
||||
(user["id"],)
|
||||
).fetchall()
|
||||
for p in places:
|
||||
km = _haversine_km(lat, lon, p["lat"], p["lon"])
|
||||
if km <= 5:
|
||||
results.append({"name": p["name"], "type": p["typ"] or "place",
|
||||
"lat": p["lat"], "lon": p["lon"],
|
||||
"distance_m": int(km * 1000), "source": "places"})
|
||||
|
||||
# 2. Gecachte OSM-POIs (nur wenn vorhanden)
|
||||
osm = conn.execute(
|
||||
"SELECT name, type, lat, lon FROM osm_pois WHERE name IS NOT NULL AND name != ''"
|
||||
).fetchall()
|
||||
for p in osm:
|
||||
km = _haversine_km(lat, lon, p["lat"], p["lon"])
|
||||
if km <= 2:
|
||||
results.append({"name": p["name"], "type": p["type"],
|
||||
"lat": p["lat"], "lon": p["lon"],
|
||||
"distance_m": int(km * 1000), "source": "osm"})
|
||||
|
||||
async with httpx.AsyncClient(timeout=6) as client:
|
||||
# 3. Overpass: benannte POIs — 800m, bei leerer Antwort auf 2000m erweitern
|
||||
try:
|
||||
def _overpass_q(radius):
|
||||
return (
|
||||
f'[out:json][timeout:6];'
|
||||
f'('
|
||||
f' node["name"]["tourism"](around:{radius},{lat},{lon});'
|
||||
f' node["name"]["historic"](around:{radius},{lat},{lon});'
|
||||
f' node["name"]["leisure"](around:{radius},{lat},{lon});'
|
||||
f' node["name"]["amenity"](around:{radius},{lat},{lon});'
|
||||
f' node["name"]["shop"](around:{radius},{lat},{lon});'
|
||||
f' way["name"]["tourism"](around:{radius},{lat},{lon});'
|
||||
f' way["name"]["historic"](around:{radius},{lat},{lon});'
|
||||
f' way["name"]["leisure"](around:{radius},{lat},{lon});'
|
||||
f');'
|
||||
f'out center;'
|
||||
)
|
||||
overpass_q = _overpass_q(800)
|
||||
ov = await client.post(
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
data={"data": overpass_q},
|
||||
headers={"User-Agent": "BanYaro/1.0"},
|
||||
)
|
||||
elements = ov.json().get("elements", []) if ov.status_code == 200 else []
|
||||
# Kein Ergebnis → Radius auf 2 km erweitern
|
||||
if not elements:
|
||||
ov2 = await client.post(
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
data={"data": _overpass_q(2000)},
|
||||
headers={"User-Agent": "BanYaro/1.0"},
|
||||
)
|
||||
elements = ov2.json().get("elements", []) if ov2.status_code == 200 else []
|
||||
if True:
|
||||
for el in elements:
|
||||
n = el.get("tags", {}).get("name")
|
||||
if not n:
|
||||
continue
|
||||
elat = el.get("lat") or el.get("center", {}).get("lat")
|
||||
elon = el.get("lon") or el.get("center", {}).get("lon")
|
||||
if elat and elon:
|
||||
km = _haversine_km(lat, lon, elat, elon)
|
||||
typ = next((el["tags"].get(k) for k in
|
||||
["tourism","historic","leisure","amenity","shop"]
|
||||
if el["tags"].get(k)), "place")
|
||||
results.append({"name": n, "type": typ,
|
||||
"lat": elat, "lon": elon,
|
||||
"distance_m": int(km * 1000), "source": "overpass"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. Nominatim Reverse-Geocode als Adress-Fallback
|
||||
try:
|
||||
r = await client.get(
|
||||
"https://nominatim.openstreetmap.org/reverse",
|
||||
params={"lat": lat, "lon": lon, "format": "json",
|
||||
"zoom": 16, "namedetails": 1, "addressdetails": 1},
|
||||
headers={"User-Agent": "BanYaro/1.0"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
d = r.json()
|
||||
# namedetails kann None sein wenn kein benanntes Feature vorhanden
|
||||
name = (d.get("name") or
|
||||
(d.get("namedetails") or {}).get("name"))
|
||||
if not name:
|
||||
addr = d.get("address", {})
|
||||
name = (addr.get("village") or addr.get("hamlet") or
|
||||
addr.get("town") or addr.get("city") or
|
||||
addr.get("suburb") or
|
||||
d.get("display_name", "").split(",")[0].strip())
|
||||
if name:
|
||||
results.append({"name": name, "type": "address",
|
||||
"lat": lat, "lon": lon,
|
||||
"distance_m": 0, "source": "nominatim"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
seen, unique = set(), []
|
||||
for r in sorted(results, key=lambda x: x["distance_m"]):
|
||||
key = r["name"].lower()
|
||||
if key not in seen:
|
||||
seen.add(key); unique.append(r)
|
||||
if len(unique) >= 12:
|
||||
break
|
||||
return unique
|
||||
|
||||
|
||||
@router.get("/{dog_id}/diary/{entry_id}")
|
||||
async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
|
|
@ -189,13 +323,13 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
|
|||
if not exists:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden.")
|
||||
|
||||
# Felder updaten
|
||||
fields = {k: v for k, v in data.model_dump(exclude={"dog_ids"}).items()
|
||||
if v is not None}
|
||||
if "tags" in fields:
|
||||
# Felder updaten — location_name/gps_* dürfen explizit auf None gesetzt werden
|
||||
raw = data.model_dump(exclude={"dog_ids"})
|
||||
NULLABLE = {"location_name", "gps_lat", "gps_lon"}
|
||||
fields = {k: v for k, v in raw.items() if v is not None or k in NULLABLE}
|
||||
if "tags" in fields and fields["tags"] is not None:
|
||||
fields["tags"] = json.dumps(fields["tags"])
|
||||
if fields:
|
||||
# primary dog_id für SET-Clause ermitteln (der Eintrag bleibt dem Erstell-Hund)
|
||||
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||
conn.execute(
|
||||
f"UPDATE diary SET {set_clause} WHERE id=?",
|
||||
|
|
@ -241,6 +375,17 @@ async def upload_media(dog_id: int, entry_id: int,
|
|||
if not entry:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden.")
|
||||
|
||||
ALLOWED = {
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp", "image/heic", "image/heif",
|
||||
"video/mp4", "video/quicktime", "video/webm", "video/x-m4v",
|
||||
}
|
||||
ct = file.content_type or ""
|
||||
if ct not in ALLOWED:
|
||||
ext_low = os.path.splitext(file.filename or "")[1].lower()
|
||||
if ext_low not in {".jpg",".jpeg",".png",".gif",".webp",".heic",".heif",
|
||||
".mp4",".mov",".webm",".m4v"}:
|
||||
raise HTTPException(415, "Nur Bilder und Videos erlaubt.")
|
||||
|
||||
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
|
||||
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
path = os.path.join(MEDIA_DIR, "diary", filename)
|
||||
|
|
@ -249,8 +394,34 @@ async def upload_media(dog_id: int, entry_id: int,
|
|||
with open(path, "wb") as f:
|
||||
f.write(await file.read())
|
||||
|
||||
media_url = f"/media/diary/{filename}"
|
||||
# Altes Medium von Disk löschen wenn vorhanden
|
||||
with db() as conn:
|
||||
old = conn.execute("SELECT media_url FROM diary WHERE id=?", (entry_id,)).fetchone()
|
||||
if old and old["media_url"]:
|
||||
old_path = os.path.join(MEDIA_DIR, old["media_url"].lstrip("/media/"))
|
||||
try: os.remove(old_path)
|
||||
except OSError: pass
|
||||
media_url = f"/media/diary/{filename}"
|
||||
conn.execute("UPDATE diary SET media_url=? WHERE id=?", (media_url, entry_id))
|
||||
|
||||
return {"media_url": media_url}
|
||||
|
||||
|
||||
@router.delete("/{dog_id}/diary/{entry_id}/media", status_code=204)
|
||||
async def delete_media(dog_id: int, entry_id: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
_own_dog(dog_id, user["id"], conn)
|
||||
row = conn.execute(
|
||||
"SELECT media_url FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden.")
|
||||
if row["media_url"]:
|
||||
path = os.path.join(MEDIA_DIR, row["media_url"].lstrip("/media/"))
|
||||
try: os.remove(path)
|
||||
except OSError: pass
|
||||
conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,))
|
||||
|
||||
|
||||
|
||||
return unique
|
||||
|
|
|
|||
|
|
@ -17,12 +17,24 @@ MAX_CSV_MB = 10
|
|||
# HTML → Plaintext
|
||||
# ------------------------------------------------------------------
|
||||
class _HTMLStripper(HTMLParser):
|
||||
_SKIP_TAGS = {"video", "audio", "script", "style"}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.parts = []
|
||||
self._skip = 0
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag.lower() in self._SKIP_TAGS:
|
||||
self._skip += 1
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag.lower() in self._SKIP_TAGS and self._skip > 0:
|
||||
self._skip -= 1
|
||||
|
||||
def handle_data(self, data):
|
||||
self.parts.append(data)
|
||||
if not self._skip:
|
||||
self.parts.append(data)
|
||||
|
||||
def handle_entityref(self, name):
|
||||
if name == "nbsp":
|
||||
|
|
|
|||
|
|
@ -53,6 +53,41 @@
|
|||
border-color: var(--c-surface-3);
|
||||
}
|
||||
|
||||
.btn-active {
|
||||
background: color-mix(in srgb, var(--c-primary) 15%, var(--c-surface)) !important;
|
||||
border-color: var(--c-primary) !important;
|
||||
color: var(--c-primary-dark) !important;
|
||||
}
|
||||
|
||||
/* Meilenstein-Toggle im Formular */
|
||||
.diary-milestone-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1.5px dashed var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--c-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.diary-milestone-toggle:hover {
|
||||
border-color: #d4a017;
|
||||
color: #8a6400;
|
||||
}
|
||||
.diary-milestone-toggle--active {
|
||||
border-style: solid;
|
||||
border-color: #d4a017;
|
||||
background: color-mix(in srgb, #d4a017 10%, transparent);
|
||||
color: #8a6400;
|
||||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.diary-milestone-toggle .ph-icon { font-size: 1.2rem; }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--c-text-secondary);
|
||||
|
|
@ -402,6 +437,11 @@
|
|||
color: var(--c-text-secondary);
|
||||
}
|
||||
|
||||
/* iOS Safari: font-size < 16px triggert Auto-Zoom beim Fokus — muss alle Klassen überschreiben */
|
||||
input, select, textarea {
|
||||
font-size: var(--text-base) !important;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
|
|
@ -624,6 +664,12 @@ textarea.form-control {
|
|||
/* ------------------------------------------------------------
|
||||
8. MODAL
|
||||
------------------------------------------------------------ */
|
||||
/* Verhindert Body-Scroll wenn Modal offen — kein Layout-Sprung */
|
||||
html.modal-open {
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -635,7 +681,8 @@ textarea.form-control {
|
|||
padding: var(--space-4);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: overlay-in var(--transition-normal) ease;
|
||||
touch-action: manipulation;
|
||||
touch-action: none;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.modal-overlay { align-items: center; }
|
||||
|
|
@ -702,9 +749,11 @@ textarea.form-control {
|
|||
font-weight: var(--weight-semibold);
|
||||
}
|
||||
.modal-body {
|
||||
padding: var(--space-6);
|
||||
overflow-y: auto;
|
||||
flex: 1; /* füllt den Raum zwischen Header und Footer */
|
||||
padding: var(--space-6);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--c-primary) var(--c-surface);
|
||||
}
|
||||
|
|
@ -977,7 +1026,7 @@ textarea.form-control {
|
|||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Foto oben */
|
||||
/* Foto / Video oben */
|
||||
.diary-card-photo {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
|
|
@ -989,6 +1038,43 @@ textarea.form-control {
|
|||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.diary-media-picker {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.diary-media-pick-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3) var(--space-2);
|
||||
border: 1.5px solid var(--c-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
.diary-media-pick-btn:hover,
|
||||
.diary-media-pick-btn:active {
|
||||
border-color: var(--c-primary);
|
||||
color: var(--c-primary);
|
||||
}
|
||||
.diary-media-pick-btn .ph-icon { font-size: 1.5rem; }
|
||||
|
||||
.diary-card-video-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--c-surface-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--c-primary);
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
/* Card Body */
|
||||
.diary-card-body {
|
||||
|
|
@ -1022,6 +1108,79 @@ textarea.form-control {
|
|||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
/* Ort-Zeile in Karte */
|
||||
.diary-card-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-primary);
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
.diary-card-location .ph-icon { flex-shrink: 0; }
|
||||
|
||||
/* Ort in Detail-Ansicht */
|
||||
.diary-detail-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-primary);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
/* Koordinaten-Zeile im Formular */
|
||||
.diary-coords-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--c-surface-2);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.diary-coords-row .ph-icon { flex-shrink: 0; }
|
||||
|
||||
/* Location-Chip im Formular */
|
||||
.diary-location-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: color-mix(in srgb, var(--c-primary) 10%, transparent);
|
||||
border: 1.5px solid var(--c-primary);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-primary-dark);
|
||||
}
|
||||
.diary-location-chip span { flex: 1; }
|
||||
.diary-location-chip button {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
color: var(--c-primary); display: flex;
|
||||
}
|
||||
|
||||
/* Vorschlagsliste */
|
||||
.diary-location-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--c-surface);
|
||||
border: 1px solid var(--c-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-1);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--c-text);
|
||||
}
|
||||
.diary-location-suggestion span { flex: 1; }
|
||||
.diary-location-suggestion small { color: var(--c-text-muted); flex-shrink: 0; }
|
||||
.diary-location-suggestion:hover { border-color: var(--c-primary); color: var(--c-primary); }
|
||||
|
||||
/* Text-Vorschau */
|
||||
.diary-card-text {
|
||||
font-size: var(--text-sm);
|
||||
|
|
|
|||
BIN
backend/static/icons/icon-192-any.png
Normal file
BIN
backend/static/icons/icon-192-any.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
backend/static/icons/icon-512-any.png
Normal file
BIN
backend/static/icons/icon-512-any.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 199 KiB |
|
|
@ -46,7 +46,7 @@
|
|||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg> Tagebuch
|
||||
</div>
|
||||
<div class="sidebar-item" data-page="health">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg> Gesundheit
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg> Gesundheit
|
||||
</div>
|
||||
<span class="sidebar-section-label">Entdecken</span>
|
||||
<div class="sidebar-item" data-page="map">
|
||||
|
|
@ -268,7 +268,7 @@
|
|||
<span class="nav-item-label">Tagebuch</span>
|
||||
</div>
|
||||
<div class="nav-item" data-page="health">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>
|
||||
<span class="nav-item-label">Gesundheit</span>
|
||||
</div>
|
||||
<!-- Mittlerer + Button -->
|
||||
|
|
@ -381,7 +381,7 @@
|
|||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
|
||||
.catch(err => console.log('SW Registration failed:', err));
|
||||
});
|
||||
// Wenn ein neuer SW die Kontrolle übernimmt (nach Update),
|
||||
|
|
|
|||
|
|
@ -114,6 +114,12 @@ const API = (() => {
|
|||
uploadMedia(dogId, id, formData) {
|
||||
return upload(`/dogs/${dogId}/diary/${id}/media`, formData);
|
||||
},
|
||||
deleteMedia(dogId, id) {
|
||||
return del(`/dogs/${dogId}/diary/${id}/media`);
|
||||
},
|
||||
nearby(dogId, lat, lon) {
|
||||
return get(`/dogs/${dogId}/diary/nearby?lat=${lat}&lon=${lon}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '133'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '164'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
|
||||
const App = (() => {
|
||||
|
||||
|
|
@ -417,8 +417,13 @@ const App = (() => {
|
|||
}
|
||||
|
||||
_updateNotifBadge();
|
||||
// Badge alle 60s aktualisieren
|
||||
setInterval(_updateNotifBadge, 60_000);
|
||||
|
||||
const pendingInvite = sessionStorage.getItem('pending_invite');
|
||||
if (pendingInvite) {
|
||||
sessionStorage.removeItem('pending_invite');
|
||||
_handleInvite(pendingInvite);
|
||||
}
|
||||
}
|
||||
|
||||
async function _updateNotifBadge() {
|
||||
|
|
@ -670,15 +675,23 @@ const App = (() => {
|
|||
history.replaceState(null, '', '/');
|
||||
return;
|
||||
}
|
||||
const ok = await UI.modal.confirm(
|
||||
`<strong>${UI.escape(info.owner_name)}</strong> möchte das Profil von
|
||||
<strong>${UI.escape(info.dog_name)}</strong> mit dir teilen
|
||||
(${info.role === 'editor' ? 'Lesen & Schreiben' : 'Nur lesen'}).
|
||||
Möchtest du die Einladung annehmen?`
|
||||
);
|
||||
|
||||
if (!state.user) {
|
||||
sessionStorage.setItem('pending_invite', token);
|
||||
history.replaceState(null, '', '/');
|
||||
navigate('settings', false);
|
||||
UI.toast.info('Bitte melde dich an, um die Einladung anzunehmen.');
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await UI.modal.confirm({
|
||||
message: `<strong>${UI.escape(info.owner_name)}</strong> möchte das Profil von
|
||||
<strong>${UI.escape(info.dog_name)}</strong> mit dir teilen
|
||||
(${info.role === 'editor' ? 'Lesen & Schreiben' : 'Nur lesen'}).
|
||||
Möchtest du die Einladung annehmen?`,
|
||||
});
|
||||
if (!ok) { history.replaceState(null, '', '/'); return; }
|
||||
await API.sharing.accept(token);
|
||||
// Hundeliste neu laden
|
||||
state.dogs = await API.dogs.list();
|
||||
const newDog = state.dogs.find(d => d.name === info.dog_name);
|
||||
if (newDog) {
|
||||
|
|
|
|||
|
|
@ -9,12 +9,51 @@ window.Page_diary = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// MODUL-STATE
|
||||
// ----------------------------------------------------------
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _entries = [];
|
||||
let _offset = 0;
|
||||
let _searchQuery = '';
|
||||
const LIMIT = 20;
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _entries = [];
|
||||
let _offset = 0;
|
||||
let _searchQuery = '';
|
||||
let _filterMilestone = false;
|
||||
const LIMIT = 20;
|
||||
|
||||
function _loadLeaflet() {
|
||||
if (window.L) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
const cssLoaded = document.querySelector('link[href*="leaflet"]')
|
||||
? Promise.resolve()
|
||||
: new Promise(res => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet'; link.href = '/css/leaflet.css';
|
||||
link.onload = res; link.onerror = res;
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
cssLoaded.then(() => {
|
||||
if (document.querySelector('script[src*="leaflet.js"]')) { resolve(); return; }
|
||||
const s = document.createElement('script');
|
||||
s.src = '/js/leaflet.js'; s.onload = resolve; s.onerror = reject;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _sourceIcon(source) {
|
||||
if (source === 'places') return 'star';
|
||||
if (source === 'osm') return 'map-pin';
|
||||
return 'map-trifold';
|
||||
}
|
||||
|
||||
const _VIDEO_EXT = new Set(['.mp4','.mov','.webm','.m4v','.avi']);
|
||||
function _isVideo(url) {
|
||||
if (!url) return false;
|
||||
return _VIDEO_EXT.has(url.slice(url.lastIndexOf('.')).toLowerCase());
|
||||
}
|
||||
function _mediaHtml(url, style = '') {
|
||||
if (!url) return '';
|
||||
return _isVideo(url)
|
||||
? `<video src="${url}" controls playsinline style="width:100%;border-radius:var(--radius-md);${style}"></video>`
|
||||
: `<img src="${url}" alt="Foto" style="width:100%;border-radius:var(--radius-md);${style}">`;
|
||||
}
|
||||
|
||||
const TYPEN = {
|
||||
eintrag: { label: 'Eintrag', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' },
|
||||
|
|
@ -31,6 +70,7 @@ window.Page_diary = (() => {
|
|||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
_loadLeaflet(); // Leaflet im Hintergrund vorladen — kein await
|
||||
await _render();
|
||||
}
|
||||
|
||||
|
|
@ -142,6 +182,9 @@ window.Page_diary = (() => {
|
|||
<input type="search" class="diary-search-input" id="diary-search-input"
|
||||
placeholder="Einträge durchsuchen…" autocomplete="off">
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm${_filterMilestone ? ' btn-active' : ''}" id="diary-milestone-filter" title="Nur Meilensteine">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" id="diary-import-btn" title="Importieren">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
|
||||
</button>
|
||||
|
|
@ -152,6 +195,16 @@ window.Page_diary = (() => {
|
|||
</div>
|
||||
`;
|
||||
|
||||
_container.querySelector('#diary-milestone-filter')
|
||||
?.addEventListener('click', async () => {
|
||||
_filterMilestone = !_filterMilestone;
|
||||
_offset = 0; _entries = [];
|
||||
const btn = _container.querySelector('#diary-milestone-filter');
|
||||
btn?.classList.toggle('btn-active', _filterMilestone);
|
||||
await _load();
|
||||
_renderList();
|
||||
});
|
||||
|
||||
_container.querySelector('#diary-import-btn')
|
||||
?.addEventListener('click', _showImport);
|
||||
_container.querySelector('#diary-btn-more')
|
||||
|
|
@ -184,6 +237,7 @@ window.Page_diary = (() => {
|
|||
try {
|
||||
const params = { limit: LIMIT, offset: _offset };
|
||||
if (_searchQuery) params.q = _searchQuery;
|
||||
if (_filterMilestone) params.milestone = 1;
|
||||
const batch = await API.diary.list(dog.id, params);
|
||||
_entries = _entries.concat(batch);
|
||||
|
||||
|
|
@ -274,7 +328,9 @@ window.Page_diary = (() => {
|
|||
|
||||
const photo = e.media_url
|
||||
? `<div class="diary-card-photo">
|
||||
<img src="${e.media_url}" alt="Foto" loading="lazy">
|
||||
${_isVideo(e.media_url)
|
||||
? `<div class="diary-card-video-thumb"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#play-circle"></use></svg></div>`
|
||||
: `<img src="${e.media_url}" alt="Foto" loading="lazy">`}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
|
|
@ -282,6 +338,10 @@ window.Page_diary = (() => {
|
|||
? `<div class="diary-card-tags">${tags.map(t => `<span class="badge">${t}</span>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
const locationHtml = e.location_name
|
||||
? `<p class="diary-card-location"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>${_escape(e.location_name)}</p>`
|
||||
: '';
|
||||
|
||||
const textPreview = e.text
|
||||
? `<p class="diary-card-text">${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}</p>`
|
||||
: '';
|
||||
|
|
@ -304,6 +364,7 @@ window.Page_diary = (() => {
|
|||
<span class="diary-card-date">${dateStr}</span>
|
||||
</div>
|
||||
${e.titel ? `<div class="diary-card-title">${_escape(e.titel)}</div>` : ''}
|
||||
${locationHtml}
|
||||
${textPreview}
|
||||
${tagsHtml}
|
||||
${dogAvatars}
|
||||
|
|
@ -336,8 +397,7 @@ window.Page_diary = (() => {
|
|||
const tags = (entry.tags || []);
|
||||
|
||||
const photo = entry.media_url
|
||||
? `<img src="${entry.media_url}" alt="Foto"
|
||||
style="width:100%;border-radius:var(--radius-md);margin-bottom:var(--space-4)">`
|
||||
? _mediaHtml(entry.media_url, 'margin-bottom:var(--space-4)')
|
||||
: '';
|
||||
|
||||
// Hunde-Anzeige wenn mehrere beteiligt
|
||||
|
|
@ -365,6 +425,11 @@ window.Page_diary = (() => {
|
|||
${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''}
|
||||
</span>
|
||||
</div>
|
||||
${entry.location_name ? `
|
||||
<div class="diary-detail-location">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
|
||||
${entry.gps_lat ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(entry.location_name)}&ll=${entry.gps_lat},${entry.gps_lon}" target="_blank" rel="noopener" style="color:inherit">${_escape(entry.location_name)}</a>` : _escape(entry.location_name)}
|
||||
</div>` : ''}
|
||||
${dogsHtml}
|
||||
${entry.text
|
||||
? `<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${_escape(entry.text)}</p>`
|
||||
|
|
@ -374,27 +439,25 @@ window.Page_diary = (() => {
|
|||
${tags.map(t => `<span class="badge">${t}</span>`).join('')}
|
||||
</div>`
|
||||
: ''}
|
||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-5)">
|
||||
<button class="btn btn-secondary flex-1" id="detail-edit">Bearbeiten</button>
|
||||
<button class="btn btn-danger flex-1" id="detail-delete">Löschen</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-5)" id="detail-edit">Bearbeiten</button>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: entry.titel || typ.label, body });
|
||||
|
||||
document.getElementById('detail-edit')?.addEventListener('click', () => {
|
||||
document.getElementById('detail-edit')?.addEventListener('click', async () => {
|
||||
UI.modal.close();
|
||||
_showForm(entry);
|
||||
});
|
||||
document.getElementById('detail-delete')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({
|
||||
title: 'Eintrag löschen?',
|
||||
message: 'Dieser Vorgang kann nicht rückgängig gemacht werden.',
|
||||
confirmText: 'Löschen',
|
||||
danger: true,
|
||||
});
|
||||
if (ok) {
|
||||
await _deleteEntry(entryId);
|
||||
// Nur nachladen wenn location_name/gps_lat fehlen (älterer In-Memory-Eintrag)
|
||||
if (entry.location_name !== undefined || entry.gps_lat !== undefined) {
|
||||
_showForm(entry);
|
||||
} else {
|
||||
try {
|
||||
const fresh = await API.diary.get(_appState.activeDog.id, entry.id);
|
||||
const idx = _entries.findIndex(e => e.id === entry.id);
|
||||
if (idx !== -1) _entries[idx] = fresh;
|
||||
_showForm(fresh);
|
||||
} catch {
|
||||
_showForm(entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -451,29 +514,104 @@ window.Page_diary = (() => {
|
|||
<textarea class="form-control" name="text" rows="5"
|
||||
placeholder="Was ist passiert? Besonderheiten, Gedanken…">${_escape(entry?.text || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group" id="diary-location-group">
|
||||
<label class="form-label">Ort <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
|
||||
<!-- Karte (Lesemodus, Edit per Button aktivierbar) -->
|
||||
<div style="position:relative">
|
||||
<div id="diary-map-wrap" style="border-radius:var(--radius-md);overflow:hidden;height:220px;background:var(--c-surface-2)"></div>
|
||||
<button type="button" id="diary-map-edit-btn" class="btn btn-secondary btn-sm"
|
||||
style="position:absolute;bottom:var(--space-2);right:var(--space-2);z-index:500">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
|
||||
<span id="diary-map-edit-label">Position ändern</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- POI-Name + Aktionen -->
|
||||
<div style="margin-top:var(--space-2)">
|
||||
<div id="diary-location-chip-wrap" style="${entry?.location_name ? '' : 'display:none'}">
|
||||
<div class="diary-location-chip">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
|
||||
<span id="diary-location-label">${_escape(entry?.location_name || '')}</span>
|
||||
<button type="button" id="diary-location-clear" aria-label="Name entfernen">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2)">
|
||||
<button type="button" class="btn btn-danger" id="diary-coords-clear">Ort entfernen</button>
|
||||
<button type="button" class="btn btn-secondary flex-1" id="diary-location-btn">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>
|
||||
<span id="diary-location-btn-label">${entry?.gps_lat ? 'POI suchen' : 'GPS → POI suchen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="diary-location-suggestions" style="display:none;margin-top:var(--space-2)"></div>
|
||||
</div>
|
||||
</div>
|
||||
${dogPickerHtml}
|
||||
<div class="form-group">
|
||||
<label class="form-label" style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||||
<input type="checkbox" name="is_milestone" ${entry?.is_milestone ? 'checked' : ''}>
|
||||
Als Meilenstein markieren
|
||||
</label>
|
||||
<input type="checkbox" name="is_milestone" id="diary-milestone-cb"
|
||||
${entry?.is_milestone ? 'checked' : ''} style="display:none">
|
||||
<button type="button" id="diary-milestone-btn"
|
||||
class="diary-milestone-toggle${entry?.is_milestone ? ' diary-milestone-toggle--active' : ''}">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trophy"></use></svg>
|
||||
<span>${entry?.is_milestone ? 'Meilenstein ✓' : 'Als Meilenstein markieren'}</span>
|
||||
</button>
|
||||
</div>
|
||||
${!isEdit ? `
|
||||
<div class="form-group">
|
||||
<label class="form-label">Foto <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
<input class="form-control" type="file" name="photo" accept="image/*">
|
||||
<img id="diary-photo-preview" style="display:none;width:100%;max-height:200px;
|
||||
object-fit:cover;border-radius:var(--radius-md);margin-top:var(--space-2)">
|
||||
<label class="form-label">Foto / Video <span style="color:var(--c-text-secondary)">(optional)</span></label>
|
||||
|
||||
${isEdit && entry.media_url ? `
|
||||
<div id="diary-current-media" style="position:relative;margin-bottom:var(--space-2)">
|
||||
${_mediaHtml(entry.media_url, 'max-height:200px;object-fit:cover')}
|
||||
<button type="button" class="btn btn-danger btn-sm" id="diary-media-delete"
|
||||
style="position:absolute;top:var(--space-2);right:var(--space-2)">
|
||||
${UI.icon('trash')}
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- versteckte Inputs -->
|
||||
<input type="file" id="diary-media-input" accept="image/*,video/*" style="display:none">
|
||||
<input type="file" id="diary-camera-input" accept="image/*,video/*" capture="environment" style="display:none">
|
||||
|
||||
<!-- Auswahlbuttons — immer sichtbar -->
|
||||
<div id="diary-media-btns" class="diary-media-picker">
|
||||
<button type="button" class="diary-media-pick-btn" id="diary-btn-camera">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#camera"></use></svg>
|
||||
Kamera
|
||||
</button>
|
||||
<button type="button" class="diary-media-pick-btn" id="diary-btn-library">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#images"></use></svg>
|
||||
Mediathek
|
||||
</button>
|
||||
<button type="button" class="diary-media-pick-btn" id="diary-btn-file">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#folder-open"></use></svg>
|
||||
Datei
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="diary-media-preview" style="display:none;margin-top:var(--space-2);position:relative">
|
||||
<img id="diary-photo-preview" style="display:none;width:100%;max-height:200px;object-fit:cover;border-radius:var(--radius-md)">
|
||||
<video id="diary-video-preview" style="display:none;width:100%;max-height:200px;border-radius:var(--radius-md)" controls playsinline></video>
|
||||
<button type="button" id="diary-preview-clear"
|
||||
style="position:absolute;top:var(--space-2);right:var(--space-2)"
|
||||
class="btn btn-danger btn-sm">${UI.icon('x')}</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</form>
|
||||
`;
|
||||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="diary-form-cancel">Abbrechen</button>
|
||||
<button type="submit" form="diary-form" class="btn btn-primary flex-1">
|
||||
${isEdit ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button type="submit" form="diary-form" class="btn btn-primary" style="width:100%">
|
||||
${isEdit ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
${isEdit ? `<button type="button" class="btn btn-danger" id="diary-form-delete">Löschen</button>` : ''}
|
||||
<button type="button" class="btn btn-secondary flex-1" id="diary-form-cancel">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: isEdit ? 'Eintrag bearbeiten' : 'Neuer Eintrag', body, footer });
|
||||
|
|
@ -483,17 +621,243 @@ window.Page_diary = (() => {
|
|||
// Fokus auf Titel-Feld → öffnet Keyboard auf Mobile, zeigt dem User was zu tun ist
|
||||
setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150);
|
||||
|
||||
// Foto-Vorschau
|
||||
const photoInput = form.querySelector('[name="photo"]');
|
||||
// Media-Inputs + Vorschau
|
||||
const mediaInput = document.getElementById('diary-media-input');
|
||||
const cameraInput = document.getElementById('diary-camera-input');
|
||||
const photoPreview = document.getElementById('diary-photo-preview');
|
||||
if (photoInput && photoPreview) {
|
||||
UI.setupPhotoPreview(photoInput, photoPreview);
|
||||
photoInput.addEventListener('change', () => {
|
||||
photoPreview.style.display = photoInput.files[0] ? 'block' : 'none';
|
||||
const videoPreview = document.getElementById('diary-video-preview');
|
||||
const previewWrap = document.getElementById('diary-media-preview');
|
||||
const mediaBtns = document.getElementById('diary-media-btns');
|
||||
|
||||
function _showPreview(file) {
|
||||
if (!file) return;
|
||||
previewWrap.style.display = '';
|
||||
if (file.type.startsWith('video/')) {
|
||||
photoPreview.style.display = 'none';
|
||||
videoPreview.style.display = '';
|
||||
videoPreview.src = URL.createObjectURL(file);
|
||||
} else {
|
||||
videoPreview.style.display = 'none';
|
||||
photoPreview.style.display = '';
|
||||
photoPreview.src = URL.createObjectURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
mediaInput?.addEventListener('change', () => _showPreview(mediaInput.files[0]));
|
||||
cameraInput?.addEventListener('change', () => {
|
||||
// Auswahl in mediaInput spiegeln damit Submit-Handler nur einen Ort abfragt
|
||||
const dt = new DataTransfer();
|
||||
if (cameraInput.files[0]) dt.items.add(cameraInput.files[0]);
|
||||
mediaInput.files = dt.files;
|
||||
_showPreview(cameraInput.files[0]);
|
||||
});
|
||||
|
||||
document.getElementById('diary-btn-camera') ?.addEventListener('click', () => cameraInput.click());
|
||||
document.getElementById('diary-btn-library')?.addEventListener('click', () => {
|
||||
// Kein capture → iOS zeigt Mediathek-Auswahl, Android zeigt Galerie
|
||||
const tmp = document.createElement('input');
|
||||
tmp.type = 'file'; tmp.accept = 'image/*,video/*'; tmp.style.display = 'none';
|
||||
tmp.addEventListener('change', () => {
|
||||
const dt = new DataTransfer();
|
||||
if (tmp.files[0]) dt.items.add(tmp.files[0]);
|
||||
mediaInput.files = dt.files;
|
||||
_showPreview(tmp.files[0]);
|
||||
tmp.remove();
|
||||
});
|
||||
document.body.appendChild(tmp);
|
||||
tmp.click();
|
||||
});
|
||||
document.getElementById('diary-btn-file')?.addEventListener('click', () => {
|
||||
mediaInput.removeAttribute('accept');
|
||||
mediaInput.click();
|
||||
mediaInput.setAttribute('accept', 'image/*,video/*');
|
||||
});
|
||||
|
||||
document.getElementById('diary-preview-clear')?.addEventListener('click', () => {
|
||||
previewWrap.style.display = 'none';
|
||||
photoPreview.src = ''; videoPreview.src = '';
|
||||
mediaInput.value = '';
|
||||
});
|
||||
|
||||
// "Entfernen"-Button löscht Medium direkt
|
||||
document.getElementById('diary-media-delete')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({
|
||||
title: `${_isVideo(entry.media_url) ? 'Video' : 'Foto'} entfernen?`,
|
||||
message: 'Das Medium wird dauerhaft gelöscht.',
|
||||
confirmText: 'Entfernen', danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await API.diary.deleteMedia(_appState.activeDog.id, entry.id);
|
||||
entry.media_url = null;
|
||||
const mediaDiv = document.getElementById('diary-current-media');
|
||||
if (mediaDiv) mediaDiv.remove();
|
||||
const replaceBtn = document.getElementById('diary-media-replace');
|
||||
if (replaceBtn) replaceBtn.remove();
|
||||
mediaInput.style.display = '';
|
||||
UI.toast.success('Medium entfernt.');
|
||||
} catch (e) { UI.toast.error(e.message || 'Fehler.'); }
|
||||
});
|
||||
|
||||
document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
// Milestone-Toggle
|
||||
document.getElementById('diary-milestone-btn')?.addEventListener('click', () => {
|
||||
const cb = document.getElementById('diary-milestone-cb');
|
||||
const btn = document.getElementById('diary-milestone-btn');
|
||||
cb.checked = !cb.checked;
|
||||
btn.classList.toggle('diary-milestone-toggle--active', cb.checked);
|
||||
btn.querySelector('span').textContent = cb.checked ? 'Meilenstein ✓' : 'Als Meilenstein markieren';
|
||||
});
|
||||
|
||||
// --- Location Picker ---
|
||||
let _locLat = (entry?.gps_lat != null) ? entry.gps_lat : null;
|
||||
let _locLon = (entry?.gps_lon != null) ? entry.gps_lon : null;
|
||||
let _locName = entry?.location_name || null;
|
||||
let _miniMap = null, _miniMarker = null;
|
||||
|
||||
const _pinSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="40" viewBox="0 0 32 40"><path d="M16 0C7.163 0 0 7.163 0 16c0 10 16 24 16 24S32 26 32 16C32 7.163 24.837 0 16 0z" fill="#C4843A"/><circle cx="16" cy="16" r="7" fill="white"/></svg>';
|
||||
const _mkIcon = () => L.divIcon({ html: _pinSvg, className: '', iconSize: [32,40], iconAnchor: [16,40] });
|
||||
|
||||
function _setName(name) {
|
||||
_locName = name;
|
||||
document.getElementById('diary-location-label').textContent = name;
|
||||
document.getElementById('diary-location-chip-wrap').style.display = '';
|
||||
document.getElementById('diary-location-suggestions').style.display = 'none';
|
||||
}
|
||||
|
||||
function _placeMarker(lat, lon) {
|
||||
if (_miniMarker) { _miniMarker.setLatLng([lat, lon]); return; }
|
||||
_miniMarker = L.marker([lat, lon], { draggable: false, icon: _mkIcon() }).addTo(_miniMap);
|
||||
_miniMarker.on('dragend', () => {
|
||||
const p = _miniMarker.getLatLng(); _locLat = p.lat; _locLon = p.lng;
|
||||
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('diary-form-cancel')?.addEventListener('click', UI.modal.close);
|
||||
document.getElementById('diary-location-clear')?.addEventListener('click', () => {
|
||||
_locName = null;
|
||||
document.getElementById('diary-location-chip-wrap').style.display = 'none';
|
||||
});
|
||||
const _clearBtn = document.getElementById('diary-coords-clear');
|
||||
let _clearPending = false;
|
||||
_clearBtn?.addEventListener('click', () => {
|
||||
if (!_clearPending) {
|
||||
_clearPending = true;
|
||||
_clearBtn.textContent = 'Wirklich entfernen?';
|
||||
_clearBtn.style.color = 'var(--c-danger)';
|
||||
setTimeout(() => {
|
||||
if (_clearPending) {
|
||||
_clearPending = false;
|
||||
_clearBtn.textContent = 'Ort entfernen';
|
||||
_clearBtn.style.color = 'var(--c-text-muted)';
|
||||
}
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
_clearPending = false;
|
||||
_clearBtn.textContent = 'Ort entfernen';
|
||||
_clearBtn.style.color = 'var(--c-text-muted)';
|
||||
_locLat = null; _locLon = null; _locName = null;
|
||||
document.getElementById('diary-location-chip-wrap').style.display = 'none';
|
||||
document.getElementById('diary-location-suggestions').style.display = 'none';
|
||||
document.getElementById('diary-location-btn-label').textContent = 'GPS → POI suchen';
|
||||
if (_miniMarker) { _miniMarker.remove(); _miniMarker = null; }
|
||||
if (_miniMap) { _miniMap.setView([48.0, 11.9], 7); _setMapEditing(false); }
|
||||
});
|
||||
|
||||
let _mapEditing = false;
|
||||
|
||||
function _setMapEditing(on) {
|
||||
_mapEditing = on;
|
||||
const lbl = document.getElementById('diary-map-edit-label');
|
||||
if (lbl) lbl.textContent = on ? 'Fertig' : 'Position ändern';
|
||||
if (!_miniMap) return;
|
||||
if (on) {
|
||||
if (_miniMarker) _miniMarker.dragging.enable();
|
||||
} else {
|
||||
if (_miniMarker) _miniMarker.dragging.disable();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('diary-map-edit-btn')?.addEventListener('click', () => {
|
||||
_setMapEditing(!_mapEditing);
|
||||
});
|
||||
|
||||
// Karte beim Formular-Open automatisch laden
|
||||
_loadLeaflet().then(() => {
|
||||
setTimeout(() => {
|
||||
const lat = _locLat || 48.0, lon = _locLon || 11.9, zoom = _locLat ? 15 : 7;
|
||||
_miniMap = L.map('diary-map-wrap', {
|
||||
zoomControl: true, attributionControl: false,
|
||||
dragging: true, scrollWheelZoom: false,
|
||||
}).setView([lat, lon], zoom);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 })
|
||||
.addTo(_miniMap);
|
||||
_miniMap.invalidateSize();
|
||||
if (_locLat) {
|
||||
_placeMarker(lat, lon);
|
||||
_miniMarker.dragging.disable(); // Lesemodus: kein Drag
|
||||
}
|
||||
// Klick nur im Edit-Modus
|
||||
_miniMap.on('click', e => {
|
||||
if (!_mapEditing) return;
|
||||
_locLat = e.latlng.lat; _locLon = e.latlng.lng;
|
||||
_placeMarker(_locLat, _locLon);
|
||||
if (!_mapEditing) _miniMarker.dragging.disable();
|
||||
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
|
||||
});
|
||||
}, 150);
|
||||
});
|
||||
|
||||
async function _showSuggestions() {
|
||||
const btn = document.getElementById('diary-location-btn');
|
||||
UI.setLoading(btn, true);
|
||||
try {
|
||||
let lat = _locLat, lon = _locLon;
|
||||
if (lat == null || lon == null) {
|
||||
const pos = await API.getLocation();
|
||||
lat = pos.lat; lon = pos.lon;
|
||||
_locLat = lat; _locLon = lon;
|
||||
if (_miniMap) { _miniMap.setView([lat, lon], 15); _placeMarker(lat, lon); }
|
||||
document.getElementById('diary-location-btn-label').textContent = 'POI suchen';
|
||||
}
|
||||
const suggestions = await API.diary.nearby(_appState.activeDog.id, lat, lon);
|
||||
const sugEl = document.getElementById('diary-location-suggestions');
|
||||
if (suggestions.length === 0) {
|
||||
sugEl.innerHTML = '<p style="font-size:var(--text-sm);color:var(--c-text-secondary);padding:var(--space-2) 0">Keine Orte in der Nähe gefunden.</p>';
|
||||
} else {
|
||||
sugEl.innerHTML = suggestions.map(s => `
|
||||
<button type="button" class="diary-location-suggestion"
|
||||
data-name="${_escape(s.name)}" data-lat="${s.lat}" data-lon="${s.lon}">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#${_sourceIcon(s.source)}"></use></svg>
|
||||
<span>${_escape(s.name)}</span>
|
||||
<small>${s.distance_m < 1000 ? s.distance_m + ' m' : (s.distance_m / 1000).toFixed(1) + ' km'}</small>
|
||||
</button>`).join('');
|
||||
sugEl.querySelectorAll('.diary-location-suggestion').forEach(el => {
|
||||
el.addEventListener('click', () => _setName(el.dataset.name));
|
||||
});
|
||||
}
|
||||
sugEl.style.display = '';
|
||||
} catch (err) {
|
||||
UI.toast.error(err?.message?.includes('GPS') || lat == null
|
||||
? 'GPS nicht verfügbar.' : 'Ortssuche fehlgeschlagen.');
|
||||
} finally {
|
||||
UI.setLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('diary-location-btn')?.addEventListener('click', _showSuggestions);
|
||||
|
||||
document.getElementById('diary-form-delete')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({
|
||||
title: 'Eintrag löschen?',
|
||||
message: 'Dieser Vorgang kann nicht rückgängig gemacht werden.',
|
||||
confirmText: 'Löschen',
|
||||
danger: true,
|
||||
});
|
||||
if (ok) await _deleteEntry(entry.id);
|
||||
});
|
||||
|
||||
// Checked-Klasse auf Dog-Picker-Items toggeln
|
||||
form.querySelectorAll('.diary-dog-pick-item input').forEach(cb => {
|
||||
|
|
@ -516,35 +880,45 @@ window.Page_diary = (() => {
|
|||
|
||||
await UI.asyncButton(submitBtn, async () => {
|
||||
const payload = {
|
||||
datum: fd.datum || null,
|
||||
typ: fd.typ,
|
||||
titel: fd.titel || null,
|
||||
text: fd.text || null,
|
||||
is_milestone: 'is_milestone' in fd,
|
||||
dog_ids: dogIds,
|
||||
datum: fd.datum || null,
|
||||
typ: fd.typ,
|
||||
titel: fd.titel || null,
|
||||
text: fd.text || null,
|
||||
is_milestone: 'is_milestone' in fd,
|
||||
dog_ids: dogIds,
|
||||
gps_lat: _locLat,
|
||||
gps_lon: _locLon,
|
||||
location_name: _locName,
|
||||
};
|
||||
|
||||
const mediaFile = mediaInput?.files[0];
|
||||
|
||||
if (isEdit) {
|
||||
const updated = await API.diary.update(_appState.activeDog.id, entry.id, payload);
|
||||
if (mediaFile) {
|
||||
try {
|
||||
const fd2 = new FormData();
|
||||
fd2.append('file', mediaFile);
|
||||
const media = await API.diary.uploadMedia(_appState.activeDog.id, entry.id, fd2);
|
||||
updated.media_url = media.media_url;
|
||||
} catch {
|
||||
UI.toast.warning('Gespeichert, Medium konnte nicht hochgeladen werden.');
|
||||
}
|
||||
}
|
||||
_updateEntryInList(updated);
|
||||
UI.toast.success('Eintrag gespeichert.');
|
||||
} else {
|
||||
const created = await API.diary.create(_appState.activeDog.id, payload);
|
||||
|
||||
// Foto hochladen wenn vorhanden
|
||||
if (photoInput?.files[0]) {
|
||||
if (mediaFile) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', photoInput.files[0]);
|
||||
const media = await API.diary.uploadMedia(
|
||||
_appState.activeDog.id, created.id, formData
|
||||
);
|
||||
const fd2 = new FormData();
|
||||
fd2.append('file', mediaFile);
|
||||
const media = await API.diary.uploadMedia(_appState.activeDog.id, created.id, fd2);
|
||||
created.media_url = media.media_url;
|
||||
} catch {
|
||||
UI.toast.warning('Eintrag erstellt, Foto konnte nicht hochgeladen werden.');
|
||||
UI.toast.warning('Eintrag erstellt, Medium konnte nicht hochgeladen werden.');
|
||||
}
|
||||
}
|
||||
|
||||
_entries.unshift(created);
|
||||
UI.toast.success('Eintrag erstellt.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,8 +248,10 @@ window.Page_dog_profile = (() => {
|
|||
value="${_esc(dog.chip_nr || '')}" placeholder="z.B. 276009200123456" maxlength="20">
|
||||
</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="chip-edit-save-btn">Speichern</button>`,
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button class="btn btn-primary" id="chip-edit-save-btn" style="width:100%">Speichern</button>
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
</div>`,
|
||||
});
|
||||
document.getElementById('chip-edit-save-btn').addEventListener('click', async () => {
|
||||
const nr = document.getElementById('chip-edit-input').value.trim() || null;
|
||||
|
|
@ -303,9 +305,13 @@ window.Page_dog_profile = (() => {
|
|||
`;
|
||||
|
||||
const footer = `
|
||||
${hasPhoto ? `<button class="btn btn-danger" id="pe-delete-btn">${UI.icon('trash')} Löschen</button>` : ''}
|
||||
<button class="btn btn-ghost" onclick="UI.modal.close()">Abbrechen</button>
|
||||
${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn">Speichern</button>` : ''}
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn" style="width:100%">Speichern</button>` : ''}
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
${hasPhoto ? `<button class="btn btn-danger" id="pe-delete-btn">${UI.icon('trash')} Löschen</button>` : ''}
|
||||
<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: 'Foto bearbeiten', body, footer });
|
||||
|
|
@ -541,8 +547,10 @@ window.Page_dog_profile = (() => {
|
|||
title: 'Weiteren Hund anlegen',
|
||||
body: _formHTML(null, true),
|
||||
footer: `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button>
|
||||
<button type="submit" form="dp-form" class="btn btn-primary flex-1">${UI.icon('dog')} Hund anlegen</button>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">${UI.icon('dog')} Hund anlegen</button>
|
||||
<button type="button" class="btn btn-secondary" id="dp-form-cancel">Abbrechen</button>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
_bindForm(null, true);
|
||||
|
|
@ -556,8 +564,13 @@ window.Page_dog_profile = (() => {
|
|||
title: `${dog.name} bearbeiten`,
|
||||
body: _formHTML(dog, true),
|
||||
footer: `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button>
|
||||
<button type="submit" form="dp-form" class="btn btn-primary flex-1">Speichern</button>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button type="submit" form="dp-form" class="btn btn-primary" style="width:100%">Speichern</button>
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<button type="button" class="btn btn-danger" id="dp-delete-btn">Löschen</button>
|
||||
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
_bindForm(dog, true);
|
||||
|
|
@ -664,15 +677,6 @@ window.Page_dog_profile = (() => {
|
|||
</button>
|
||||
</div>` : ''}
|
||||
|
||||
${dog ? `
|
||||
<div style="margin-top:var(--space-5);padding-top:var(--space-4);
|
||||
border-top:1px solid var(--c-border);text-align:center">
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="dp-delete-btn"
|
||||
style="color:var(--c-danger)">
|
||||
${dog.name} löschen
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
</form>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -543,14 +543,26 @@ window.Page_events = (() => {
|
|||
`;
|
||||
|
||||
const footer = `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" type="submit" form="${id}" id="ev-submit-btn">
|
||||
${isEdit ? 'Speichern' : 'Event erstellen'}
|
||||
</button>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button class="btn btn-primary" type="submit" form="${id}" id="ev-submit-btn" style="width:100%">
|
||||
${isEdit ? 'Speichern' : 'Event erstellen'}
|
||||
</button>
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
${isEdit ? `<button type="button" class="btn btn-danger" id="ev-form-delete">Löschen</button>` : ''}
|
||||
<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: isEdit ? 'Event bearbeiten' : 'Neues Event', body, footer });
|
||||
|
||||
document.getElementById('ev-form-delete')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({
|
||||
title: 'Event löschen?', message: 'Nicht rückgängig.', confirmText: 'Löschen', danger: true,
|
||||
});
|
||||
if (ok) await _deleteEvent(ev);
|
||||
});
|
||||
|
||||
document.getElementById('ev-gps-btn')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const pos = await API.getLocation();
|
||||
|
|
|
|||
|
|
@ -911,10 +911,7 @@ window.Page_health = (() => {
|
|||
: `<img src="${entry.datei_url}" style="width:100%;border-radius:var(--radius-md);margin-top:var(--space-3)" alt="Dokument">`)
|
||||
: ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-5)">
|
||||
<button class="btn btn-secondary flex-1" id="health-detail-edit">Bearbeiten</button>
|
||||
<button class="btn btn-danger flex-1" id="health-detail-delete">Löschen</button>
|
||||
</div>
|
||||
<button class="btn btn-secondary" style="width:100%;margin-top:var(--space-5)" id="health-detail-edit">Bearbeiten</button>
|
||||
`;
|
||||
|
||||
const modalTitle = entry.typ === 'gewicht'
|
||||
|
|
@ -928,25 +925,6 @@ window.Page_health = (() => {
|
|||
UI.modal.close();
|
||||
_showForm(entry, entry.typ);
|
||||
});
|
||||
document.getElementById('health-detail-delete')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({
|
||||
title: 'Eintrag löschen?',
|
||||
message: 'Dieser Vorgang kann nicht rückgängig gemacht werden.',
|
||||
confirmText: 'Löschen',
|
||||
danger: true,
|
||||
});
|
||||
if (ok) {
|
||||
try {
|
||||
await API.health.delete(_appState.activeDog.id, entry.id);
|
||||
_data[entry.typ] = (_data[entry.typ] || []).filter(e => e.id !== entry.id);
|
||||
UI.modal.close();
|
||||
_renderTab();
|
||||
UI.toast.success('Eintrag gelöscht.');
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Löschen.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _detailFields(e) {
|
||||
|
|
@ -1031,8 +1009,13 @@ window.Page_health = (() => {
|
|||
`;
|
||||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="health-form-cancel">Abbrechen</button>
|
||||
<button type="submit" form="health-form" class="btn btn-primary flex-1">${isEdit ? 'Speichern' : 'Erstellen'}</button>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button type="submit" form="health-form" class="btn btn-primary" style="width:100%">${isEdit ? 'Speichern' : 'Erstellen'}</button>
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
${isEdit ? `<button type="button" class="btn btn-danger" id="health-form-delete">Löschen</button>` : ''}
|
||||
<button type="button" class="btn btn-secondary flex-1" id="health-form-cancel">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const tabInfo = _getTabs().find(tab => tab.key === t) || BASE_TABS[0];
|
||||
|
|
@ -1051,6 +1034,21 @@ window.Page_health = (() => {
|
|||
|
||||
document.getElementById('health-form-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
document.getElementById('health-form-delete')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({
|
||||
title: 'Eintrag löschen?', message: 'Nicht rückgängig.', confirmText: 'Löschen', danger: true,
|
||||
});
|
||||
if (ok) {
|
||||
try {
|
||||
await API.health.delete(_appState.activeDog.id, entry.id);
|
||||
_data[t] = (_data[t] || []).filter(e => e.id !== entry.id);
|
||||
UI.modal.close();
|
||||
_renderTab();
|
||||
UI.toast.success('Eintrag gelöscht.');
|
||||
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.querySelector('[form="health-form"][type="submit"]') || form.querySelector('[type="submit"]');
|
||||
|
|
|
|||
|
|
@ -295,9 +295,8 @@ window.Page_places = (() => {
|
|||
`;
|
||||
|
||||
const footer = isOwn ? `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="place-detail-close">Schließen</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="place-detail-delete" style="color:var(--c-danger)">Löschen</button>
|
||||
<button type="button" class="btn btn-primary flex-1" id="place-detail-edit">Bearbeiten</button>
|
||||
<button type="button" class="btn btn-secondary" style="width:100%" id="place-detail-edit">Bearbeiten</button>
|
||||
<button type="button" class="btn btn-ghost" style="width:100%;margin-top:var(--space-2)" id="place-detail-close">Schließen</button>
|
||||
` : `
|
||||
<button type="button" class="btn btn-primary flex-1" id="place-detail-close">Schließen</button>
|
||||
`;
|
||||
|
|
@ -311,25 +310,6 @@ window.Page_places = (() => {
|
|||
_showForm(place);
|
||||
});
|
||||
|
||||
document.getElementById('place-detail-delete')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({
|
||||
title: 'Ort löschen?',
|
||||
message: `„${place.name}" wird dauerhaft entfernt.`,
|
||||
confirmText: 'Löschen',
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await API.places.delete(place.id);
|
||||
_data = _data.filter(p => p.id !== place.id);
|
||||
UI.modal.close();
|
||||
_renderList();
|
||||
_renderMarkers();
|
||||
UI.toast.success('Ort gelöscht.');
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Löschen.');
|
||||
}
|
||||
});
|
||||
|
||||
// Auf Karte zentrieren
|
||||
if (_map) _map.setView([place.lat, place.lon], 15);
|
||||
|
|
@ -415,16 +395,36 @@ window.Page_places = (() => {
|
|||
`;
|
||||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="place-form-cancel">Abbrechen</button>
|
||||
<button type="submit" form="place-form" class="btn btn-primary flex-1">
|
||||
${isEdit ? 'Speichern' : 'Ort hinzufügen'}
|
||||
</button>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button type="submit" form="place-form" class="btn btn-primary" style="width:100%">
|
||||
${isEdit ? 'Speichern' : 'Ort hinzufügen'}
|
||||
</button>
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
${isEdit ? `<button type="button" class="btn btn-danger" id="place-form-delete">Löschen</button>` : ''}
|
||||
<button type="button" class="btn btn-secondary flex-1" id="place-form-cancel">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: isEdit ? `${_esc(place.name)} bearbeiten` : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Neuer Ort', body, footer });
|
||||
|
||||
document.getElementById('place-form-cancel')?.addEventListener('click', UI.modal.close);
|
||||
|
||||
document.getElementById('place-form-delete')?.addEventListener('click', async () => {
|
||||
const ok = await UI.modal.confirm({
|
||||
title: 'Ort löschen?', message: `„${place.name}" wird dauerhaft entfernt.`, confirmText: 'Löschen', danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await API.places.delete(place.id);
|
||||
_data = _data.filter(p => p.id !== place.id);
|
||||
UI.modal.close();
|
||||
_renderList();
|
||||
_renderMarkers();
|
||||
UI.toast.success('Ort gelöscht.');
|
||||
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
|
||||
});
|
||||
|
||||
// GPS-Button
|
||||
document.getElementById('pf-gps-btn')?.addEventListener('click', async () => {
|
||||
const btn = document.getElementById('pf-gps-btn');
|
||||
|
|
|
|||
|
|
@ -316,8 +316,10 @@ window.Page_settings = (() => {
|
|||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<button type="submit" form="profile-form" class="btn btn-primary">Speichern</button>
|
||||
<button type="button" class="btn btn-ghost" data-modal-close>Abbrechen</button>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button type="submit" form="profile-form" class="btn btn-primary" style="width:100%">Speichern</button>
|
||||
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -399,10 +399,12 @@ window.Page_sitting = (() => {
|
|||
</form>
|
||||
`;
|
||||
const footer = `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-primary" type="submit" form="${id}" id="sit-profil-submit">
|
||||
${s ? `${UI.icon('floppy-disk')} Speichern` : `${UI.icon('plus')} Profil erstellen`}
|
||||
</button>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button class="btn btn-primary" type="submit" form="${id}" id="sit-profil-submit" style="width:100%">
|
||||
${s ? `${UI.icon('floppy-disk')} Speichern` : `${UI.icon('plus')} Profil erstellen`}
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
</div>
|
||||
`;
|
||||
UI.modal.open({ title: s ? 'Sitter-Profil bearbeiten' : 'Sitter-Profil erstellen', body, footer });
|
||||
|
||||
|
|
|
|||
|
|
@ -511,10 +511,12 @@ window.Page_walks = (() => {
|
|||
`;
|
||||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" id="wf-cancel">Abbrechen</button>
|
||||
<button type="submit" form="walk-form" class="btn btn-primary flex-1">
|
||||
${isEdit ? 'Speichern' : `${UI.icon('calendar-dots')} Treffen planen`}
|
||||
</button>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
|
||||
<button type="submit" form="walk-form" class="btn btn-primary" style="width:100%">
|
||||
${isEdit ? 'Speichern' : `${UI.icon('calendar-dots')} Treffen planen`}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" id="wf-cancel">Abbrechen</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: isEdit ? 'Treffen bearbeiten' : `${UI.icon('dog')} Treffen planen`, body, footer });
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ const UI = (() => {
|
|||
overlay.querySelector('.modal-close-btn')?.addEventListener('click', close);
|
||||
|
||||
document.getElementById('modal-container').appendChild(overlay);
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.documentElement.classList.add('modal-open');
|
||||
_current = { overlay, onClose };
|
||||
|
||||
return overlay.querySelector('.modal');
|
||||
|
|
@ -85,10 +85,18 @@ const UI = (() => {
|
|||
|
||||
function close() {
|
||||
if (!_current) return;
|
||||
_current.onClose?.();
|
||||
const { onClose } = _current;
|
||||
onClose?.();
|
||||
_current.overlay.remove();
|
||||
document.body.style.overflow = '';
|
||||
document.documentElement.classList.remove('modal-open');
|
||||
_current = null;
|
||||
// iOS Safari setzt den Zoom nach Input-Fokus nicht zurück — Viewport kurz neu setzen
|
||||
const meta = document.querySelector('meta[name="viewport"]');
|
||||
if (meta) {
|
||||
const orig = meta.content;
|
||||
meta.content = orig + ',maximum-scale=1';
|
||||
requestAnimationFrame(() => { meta.content = orig; });
|
||||
}
|
||||
}
|
||||
|
||||
// Bestätigungsdialog
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@
|
|||
"lang": "de",
|
||||
"categories": ["lifestyle", "social"],
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/icons/icon-180.png", "sizes": "180x180", "type": "image/png" }
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-180.png", "sizes": "180x180", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-192-any.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
|
||||
{ "src": "/icons/icon-512-any.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v161';
|
||||
const CACHE_VERSION = 'by-v193';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue