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:
rene 2026-04-18 11:56:54 +02:00
parent 88912e2746
commit f8d354749d
19 changed files with 963 additions and 198 deletions

View file

@ -1,35 +1,40 @@
"""BAN YARO — Tagebuch Routes""" """BAN YARO — Tagebuch Routes"""
import os, uuid, json import os, uuid, json, math
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
import ki as KI import ki as KI
import httpx
router = APIRouter() router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DiaryCreate(BaseModel): class DiaryCreate(BaseModel):
datum: Optional[str] = None # ISO date, default heute datum: Optional[str] = None # ISO date, default heute
typ: str = "eintrag" typ: str = "eintrag"
titel: Optional[str] = None titel: Optional[str] = None
text: Optional[str] = None text: Optional[str] = None
tags: Optional[list] = None tags: Optional[list] = None
gps_lat: Optional[float] = None gps_lat: Optional[float] = None
gps_lon: Optional[float] = None gps_lon: Optional[float] = None
is_milestone: bool = False location_name: Optional[str] = None
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary is_milestone: bool = False
dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
class DiaryUpdate(BaseModel): class DiaryUpdate(BaseModel):
titel: Optional[str] = None titel: Optional[str] = None
text: Optional[str] = None text: Optional[str] = None
tags: Optional[list] = None tags: Optional[list] = None
is_milestone: Optional[bool] = None gps_lat: Optional[float] = None
dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen 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): 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") @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, q: Optional[str] = None, milestone: int = 0,
user=Depends(get_current_user)): user=Depends(get_current_user)):
with db() as conn: with db() as conn:
_own_dog(dog_id, user["id"], conn) _own_dog(dog_id, user["id"], conn)
extra = "AND (d.is_milestone=1 OR d.typ='meilenstein')" if milestone else ""
if q: if q:
pattern = f"%{q}%" pattern = f"%{q}%"
rows = conn.execute( 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 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 = ?)
AND (d.titel LIKE ? OR d.text LIKE ? OR d.tags LIKE ?) AND (d.titel LIKE ? OR d.text LIKE ? OR d.tags LIKE ?)
{extra}
ORDER BY d.datum DESC, d.created_at DESC ORDER BY d.datum DESC, d.created_at DESC
LIMIT ? OFFSET ?""", LIMIT ? OFFSET ?""",
(dog_id, dog_id, pattern, pattern, pattern, limit, offset) (dog_id, dog_id, pattern, pattern, pattern, limit, offset)
).fetchall() ).fetchall()
else: else:
rows = conn.execute( 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 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 ORDER BY d.datum DESC, d.created_at DESC
LIMIT ? OFFSET ?""", LIMIT ? OFFSET ?""",
(dog_id, dog_id, limit, offset) (dog_id, dog_id, limit, offset)
@ -139,12 +146,12 @@ async def create_diary(dog_id: int, data: DiaryCreate,
conn.execute( conn.execute(
"""INSERT INTO diary """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 (?, VALUES (?,
COALESCE(?, date('now')), COALESCE(?, date('now')),
?,?,?,?,?,?,?)""", ?,?,?,?,?,?,?,?)""",
(dog_id, data.datum, data.typ, data.titel, data.text, (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( entry = conn.execute(
"SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1", "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) 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}") @router.get("/{dog_id}/diary/{entry_id}")
async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)): async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
with db() as conn: with db() as conn:
@ -189,13 +323,13 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
if not exists: if not exists:
raise HTTPException(404, "Eintrag nicht gefunden.") raise HTTPException(404, "Eintrag nicht gefunden.")
# Felder updaten # Felder updaten — location_name/gps_* dürfen explizit auf None gesetzt werden
fields = {k: v for k, v in data.model_dump(exclude={"dog_ids"}).items() raw = data.model_dump(exclude={"dog_ids"})
if v is not None} NULLABLE = {"location_name", "gps_lat", "gps_lon"}
if "tags" in fields: 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"]) fields["tags"] = json.dumps(fields["tags"])
if fields: 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) set_clause = ", ".join(f"{k}=?" for k in fields)
conn.execute( conn.execute(
f"UPDATE diary SET {set_clause} WHERE id=?", 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: if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.") 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" ext = os.path.splitext(file.filename or "")[1] or ".jpg"
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}" filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
path = os.path.join(MEDIA_DIR, "diary", filename) 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: with open(path, "wb") as f:
f.write(await file.read()) f.write(await file.read())
media_url = f"/media/diary/{filename}" # Altes Medium von Disk löschen wenn vorhanden
with db() as conn: 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)) conn.execute("UPDATE diary SET media_url=? WHERE id=?", (media_url, entry_id))
return {"media_url": media_url} 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

View file

@ -17,12 +17,24 @@ MAX_CSV_MB = 10
# HTML → Plaintext # HTML → Plaintext
# ------------------------------------------------------------------ # ------------------------------------------------------------------
class _HTMLStripper(HTMLParser): class _HTMLStripper(HTMLParser):
_SKIP_TAGS = {"video", "audio", "script", "style"}
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.parts = [] 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): def handle_data(self, data):
self.parts.append(data) if not self._skip:
self.parts.append(data)
def handle_entityref(self, name): def handle_entityref(self, name):
if name == "nbsp": if name == "nbsp":

View file

@ -53,6 +53,41 @@
border-color: var(--c-surface-3); 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 { .btn-ghost {
background: transparent; background: transparent;
color: var(--c-text-secondary); color: var(--c-text-secondary);
@ -402,6 +437,11 @@
color: var(--c-text-secondary); 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 { .form-control {
width: 100%; width: 100%;
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
@ -624,6 +664,12 @@ textarea.form-control {
/* ------------------------------------------------------------ /* ------------------------------------------------------------
8. MODAL 8. MODAL
------------------------------------------------------------ */ ------------------------------------------------------------ */
/* Verhindert Body-Scroll wenn Modal offen — kein Layout-Sprung */
html.modal-open {
overflow: hidden;
overscroll-behavior: none;
}
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@ -635,7 +681,8 @@ textarea.form-control {
padding: var(--space-4); padding: var(--space-4);
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
animation: overlay-in var(--transition-normal) ease; animation: overlay-in var(--transition-normal) ease;
touch-action: manipulation; touch-action: none;
overscroll-behavior: none;
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.modal-overlay { align-items: center; } .modal-overlay { align-items: center; }
@ -702,9 +749,11 @@ textarea.form-control {
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
} }
.modal-body { .modal-body {
padding: var(--space-6); padding: var(--space-6);
overflow-y: auto; overflow-y: auto;
flex: 1; /* füllt den Raum zwischen Header und Footer */ flex: 1;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--c-primary) var(--c-surface); scrollbar-color: var(--c-primary) var(--c-surface);
} }
@ -977,7 +1026,7 @@ textarea.form-control {
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
/* Foto oben */ /* Foto / Video oben */
.diary-card-photo { .diary-card-photo {
width: 100%; width: 100%;
height: 180px; height: 180px;
@ -989,6 +1038,43 @@ textarea.form-control {
object-fit: cover; object-fit: cover;
display: block; 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 */ /* Card Body */
.diary-card-body { .diary-card-body {
@ -1022,6 +1108,79 @@ textarea.form-control {
margin-bottom: var(--space-1); 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 */ /* Text-Vorschau */
.diary-card-text { .diary-card-text {
font-size: var(--text-sm); font-size: var(--text-sm);

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

View file

@ -46,7 +46,7 @@
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg> Tagebuch <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg> Tagebuch
</div> </div>
<div class="sidebar-item" data-page="health"> <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> </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">
@ -268,7 +268,7 @@
<span class="nav-item-label">Tagebuch</span> <span class="nav-item-label">Tagebuch</span>
</div> </div>
<div class="nav-item" data-page="health"> <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> <span class="nav-item-label">Gesundheit</span>
</div> </div>
<!-- Mittlerer + Button --> <!-- Mittlerer + Button -->
@ -381,7 +381,7 @@
<script> <script>
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js') navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
.catch(err => console.log('SW Registration failed:', err)); .catch(err => console.log('SW Registration failed:', err));
}); });
// Wenn ein neuer SW die Kontrolle übernimmt (nach Update), // Wenn ein neuer SW die Kontrolle übernimmt (nach Update),

View file

@ -114,6 +114,12 @@ const API = (() => {
uploadMedia(dogId, id, formData) { uploadMedia(dogId, id, formData) {
return upload(`/dogs/${dogId}/diary/${id}/media`, 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}`);
},
}; };
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 = (() => { const App = (() => {
@ -417,8 +417,13 @@ const App = (() => {
} }
_updateNotifBadge(); _updateNotifBadge();
// Badge alle 60s aktualisieren
setInterval(_updateNotifBadge, 60_000); setInterval(_updateNotifBadge, 60_000);
const pendingInvite = sessionStorage.getItem('pending_invite');
if (pendingInvite) {
sessionStorage.removeItem('pending_invite');
_handleInvite(pendingInvite);
}
} }
async function _updateNotifBadge() { async function _updateNotifBadge() {
@ -670,15 +675,23 @@ const App = (() => {
history.replaceState(null, '', '/'); history.replaceState(null, '', '/');
return; return;
} }
const ok = await UI.modal.confirm(
`<strong>${UI.escape(info.owner_name)}</strong> möchte das Profil von if (!state.user) {
<strong>${UI.escape(info.dog_name)}</strong> mit dir teilen sessionStorage.setItem('pending_invite', token);
(${info.role === 'editor' ? 'Lesen & Schreiben' : 'Nur lesen'}). history.replaceState(null, '', '/');
Möchtest du die Einladung annehmen?` 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; } if (!ok) { history.replaceState(null, '', '/'); return; }
await API.sharing.accept(token); await API.sharing.accept(token);
// Hundeliste neu laden
state.dogs = await API.dogs.list(); state.dogs = await API.dogs.list();
const newDog = state.dogs.find(d => d.name === info.dog_name); const newDog = state.dogs.find(d => d.name === info.dog_name);
if (newDog) { if (newDog) {

View file

@ -9,12 +9,51 @@ window.Page_diary = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// MODUL-STATE // MODUL-STATE
// ---------------------------------------------------------- // ----------------------------------------------------------
let _container = null; let _container = null;
let _appState = null; let _appState = null;
let _entries = []; let _entries = [];
let _offset = 0; let _offset = 0;
let _searchQuery = ''; let _searchQuery = '';
const LIMIT = 20; 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 = { const TYPEN = {
eintrag: { label: 'Eintrag', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' }, 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) { async function init(container, appState) {
_container = container; _container = container;
_appState = appState; _appState = appState;
_loadLeaflet(); // Leaflet im Hintergrund vorladen — kein await
await _render(); await _render();
} }
@ -142,6 +182,9 @@ window.Page_diary = (() => {
<input type="search" class="diary-search-input" id="diary-search-input" <input type="search" class="diary-search-input" id="diary-search-input"
placeholder="Einträge durchsuchen…" autocomplete="off"> placeholder="Einträge durchsuchen…" autocomplete="off">
</div> </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"> <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> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#download-simple"></use></svg>
</button> </button>
@ -152,6 +195,16 @@ window.Page_diary = (() => {
</div> </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') _container.querySelector('#diary-import-btn')
?.addEventListener('click', _showImport); ?.addEventListener('click', _showImport);
_container.querySelector('#diary-btn-more') _container.querySelector('#diary-btn-more')
@ -184,6 +237,7 @@ window.Page_diary = (() => {
try { try {
const params = { limit: LIMIT, offset: _offset }; const params = { limit: LIMIT, offset: _offset };
if (_searchQuery) params.q = _searchQuery; if (_searchQuery) params.q = _searchQuery;
if (_filterMilestone) params.milestone = 1;
const batch = await API.diary.list(dog.id, params); const batch = await API.diary.list(dog.id, params);
_entries = _entries.concat(batch); _entries = _entries.concat(batch);
@ -274,7 +328,9 @@ window.Page_diary = (() => {
const photo = e.media_url const photo = e.media_url
? `<div class="diary-card-photo"> ? `<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>` </div>`
: ''; : '';
@ -282,6 +338,10 @@ window.Page_diary = (() => {
? `<div class="diary-card-tags">${tags.map(t => `<span class="badge">${t}</span>`).join('')}</div>` ? `<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 const textPreview = e.text
? `<p class="diary-card-text">${_escape(e.text.slice(0, 140))}${e.text.length > 140 ? '…' : ''}</p>` ? `<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> <span class="diary-card-date">${dateStr}</span>
</div> </div>
${e.titel ? `<div class="diary-card-title">${_escape(e.titel)}</div>` : ''} ${e.titel ? `<div class="diary-card-title">${_escape(e.titel)}</div>` : ''}
${locationHtml}
${textPreview} ${textPreview}
${tagsHtml} ${tagsHtml}
${dogAvatars} ${dogAvatars}
@ -336,8 +397,7 @@ window.Page_diary = (() => {
const tags = (entry.tags || []); const tags = (entry.tags || []);
const photo = entry.media_url const photo = entry.media_url
? `<img src="${entry.media_url}" alt="Foto" ? _mediaHtml(entry.media_url, 'margin-bottom:var(--space-4)')
style="width:100%;border-radius:var(--radius-md);margin-bottom:var(--space-4)">`
: ''; : '';
// Hunde-Anzeige wenn mehrere beteiligt // Hunde-Anzeige wenn mehrere beteiligt
@ -365,6 +425,11 @@ window.Page_diary = (() => {
${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''} ${entry.datum ? UI.time.format(entry.datum + 'T00:00:00') : ''}
</span> </span>
</div> </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} ${dogsHtml}
${entry.text ${entry.text
? `<p style="white-space:pre-wrap;line-height:1.6;color:var(--c-text)">${_escape(entry.text)}</p>` ? `<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('')} ${tags.map(t => `<span class="badge">${t}</span>`).join('')}
</div>` </div>`
: ''} : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-5)"> <button class="btn btn-secondary" style="width:100%;margin-top:var(--space-5)" id="detail-edit">Bearbeiten</button>
<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>
`; `;
UI.modal.open({ title: entry.titel || typ.label, body }); 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(); UI.modal.close();
_showForm(entry); // Nur nachladen wenn location_name/gps_lat fehlen (älterer In-Memory-Eintrag)
}); if (entry.location_name !== undefined || entry.gps_lat !== undefined) {
document.getElementById('detail-delete')?.addEventListener('click', async () => { _showForm(entry);
const ok = await UI.modal.confirm({ } else {
title: 'Eintrag löschen?', try {
message: 'Dieser Vorgang kann nicht rückgängig gemacht werden.', const fresh = await API.diary.get(_appState.activeDog.id, entry.id);
confirmText: 'Löschen', const idx = _entries.findIndex(e => e.id === entry.id);
danger: true, if (idx !== -1) _entries[idx] = fresh;
}); _showForm(fresh);
if (ok) { } catch {
await _deleteEntry(entryId); _showForm(entry);
}
} }
}); });
} }
@ -451,29 +514,104 @@ window.Page_diary = (() => {
<textarea class="form-control" name="text" rows="5" <textarea class="form-control" name="text" rows="5"
placeholder="Was ist passiert? Besonderheiten, Gedanken…">${_escape(entry?.text || '')}</textarea> placeholder="Was ist passiert? Besonderheiten, Gedanken…">${_escape(entry?.text || '')}</textarea>
</div> </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} ${dogPickerHtml}
<div class="form-group"> <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" id="diary-milestone-cb"
<input type="checkbox" name="is_milestone" ${entry?.is_milestone ? 'checked' : ''}> ${entry?.is_milestone ? 'checked' : ''} style="display:none">
Als Meilenstein markieren <button type="button" id="diary-milestone-btn"
</label> 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> </div>
${!isEdit ? `
<div class="form-group"> <div class="form-group">
<label class="form-label">Foto <span style="color:var(--c-text-secondary)">(optional)</span></label> <label class="form-label">Foto / Video <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; ${isEdit && entry.media_url ? `
object-fit:cover;border-radius:var(--radius-md);margin-top:var(--space-2)"> <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> </div>
` : ''}
</form> </form>
`; `;
const footer = ` const footer = `
<button type="button" class="btn btn-secondary flex-1" id="diary-form-cancel">Abbrechen</button> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="diary-form" class="btn btn-primary flex-1"> <button type="submit" form="diary-form" class="btn btn-primary" style="width:100%">
${isEdit ? 'Speichern' : 'Erstellen'} ${isEdit ? 'Speichern' : 'Erstellen'}
</button> </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 }); 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 // Fokus auf Titel-Feld → öffnet Keyboard auf Mobile, zeigt dem User was zu tun ist
setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150); setTimeout(() => form?.querySelector('[name="titel"]')?.focus(), 150);
// Foto-Vorschau // Media-Inputs + Vorschau
const photoInput = form.querySelector('[name="photo"]'); const mediaInput = document.getElementById('diary-media-input');
const cameraInput = document.getElementById('diary-camera-input');
const photoPreview = document.getElementById('diary-photo-preview'); const photoPreview = document.getElementById('diary-photo-preview');
if (photoInput && photoPreview) { const videoPreview = document.getElementById('diary-video-preview');
UI.setupPhotoPreview(photoInput, photoPreview); const previewWrap = document.getElementById('diary-media-preview');
photoInput.addEventListener('change', () => { const mediaBtns = document.getElementById('diary-media-btns');
photoPreview.style.display = photoInput.files[0] ? 'block' : 'none';
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 // Checked-Klasse auf Dog-Picker-Items toggeln
form.querySelectorAll('.diary-dog-pick-item input').forEach(cb => { form.querySelectorAll('.diary-dog-pick-item input').forEach(cb => {
@ -516,35 +880,45 @@ window.Page_diary = (() => {
await UI.asyncButton(submitBtn, async () => { await UI.asyncButton(submitBtn, async () => {
const payload = { const payload = {
datum: fd.datum || null, datum: fd.datum || null,
typ: fd.typ, typ: fd.typ,
titel: fd.titel || null, titel: fd.titel || null,
text: fd.text || null, text: fd.text || null,
is_milestone: 'is_milestone' in fd, is_milestone: 'is_milestone' in fd,
dog_ids: dogIds, dog_ids: dogIds,
gps_lat: _locLat,
gps_lon: _locLon,
location_name: _locName,
}; };
const mediaFile = mediaInput?.files[0];
if (isEdit) { if (isEdit) {
const updated = await API.diary.update(_appState.activeDog.id, entry.id, payload); 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); _updateEntryInList(updated);
UI.toast.success('Eintrag gespeichert.'); UI.toast.success('Eintrag gespeichert.');
} else { } else {
const created = await API.diary.create(_appState.activeDog.id, payload); const created = await API.diary.create(_appState.activeDog.id, payload);
if (mediaFile) {
// Foto hochladen wenn vorhanden
if (photoInput?.files[0]) {
try { try {
const formData = new FormData(); const fd2 = new FormData();
formData.append('file', photoInput.files[0]); fd2.append('file', mediaFile);
const media = await API.diary.uploadMedia( const media = await API.diary.uploadMedia(_appState.activeDog.id, created.id, fd2);
_appState.activeDog.id, created.id, formData
);
created.media_url = media.media_url; created.media_url = media.media_url;
} catch { } catch {
UI.toast.warning('Eintrag erstellt, Foto konnte nicht hochgeladen werden.'); UI.toast.warning('Eintrag erstellt, Medium konnte nicht hochgeladen werden.');
} }
} }
_entries.unshift(created); _entries.unshift(created);
UI.toast.success('Eintrag erstellt.'); UI.toast.success('Eintrag erstellt.');
} }

View file

@ -248,8 +248,10 @@ window.Page_dog_profile = (() => {
value="${_esc(dog.chip_nr || '')}" placeholder="z.B. 276009200123456" maxlength="20"> value="${_esc(dog.chip_nr || '')}" placeholder="z.B. 276009200123456" maxlength="20">
</div>`, </div>`,
footer: ` footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-primary" id="chip-edit-save-btn">Speichern</button>`, <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 () => { document.getElementById('chip-edit-save-btn').addEventListener('click', async () => {
const nr = document.getElementById('chip-edit-input').value.trim() || null; const nr = document.getElementById('chip-edit-input').value.trim() || null;
@ -303,9 +305,13 @@ window.Page_dog_profile = (() => {
`; `;
const footer = ` const footer = `
${hasPhoto ? `<button class="btn btn-danger" id="pe-delete-btn">${UI.icon('trash')} Löschen</button>` : ''} <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-ghost" onclick="UI.modal.close()">Abbrechen</button> ${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn" style="width:100%">Speichern</button>` : ''}
${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn">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 }); UI.modal.open({ title: 'Foto bearbeiten', body, footer });
@ -541,8 +547,10 @@ window.Page_dog_profile = (() => {
title: 'Weiteren Hund anlegen', title: 'Weiteren Hund anlegen',
body: _formHTML(null, true), body: _formHTML(null, true),
footer: ` footer: `
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="dp-form" class="btn btn-primary flex-1">${UI.icon('dog')} Hund anlegen</button> <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); _bindForm(null, true);
@ -556,8 +564,13 @@ window.Page_dog_profile = (() => {
title: `${dog.name} bearbeiten`, title: `${dog.name} bearbeiten`,
body: _formHTML(dog, true), body: _formHTML(dog, true),
footer: ` footer: `
<button type="button" class="btn btn-secondary flex-1" id="dp-form-cancel">Abbrechen</button> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="dp-form" class="btn btn-primary flex-1">Speichern</button> <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); _bindForm(dog, true);
@ -664,15 +677,6 @@ window.Page_dog_profile = (() => {
</button> </button>
</div>` : ''} </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> </form>
`; `;

View file

@ -543,14 +543,26 @@ window.Page_events = (() => {
`; `;
const footer = ` const footer = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</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"> <button class="btn btn-primary" type="submit" form="${id}" id="ev-submit-btn" style="width:100%">
${isEdit ? 'Speichern' : 'Event erstellen'} ${isEdit ? 'Speichern' : 'Event erstellen'}
</button> </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 }); 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 () => { document.getElementById('ev-gps-btn')?.addEventListener('click', async () => {
try { try {
const pos = await API.getLocation(); const pos = await API.getLocation();

View file

@ -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">`) : `<img src="${entry.datei_url}" style="width:100%;border-radius:var(--radius-md);margin-top:var(--space-3)" alt="Dokument">`)
: ''} : ''}
</div> </div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-5)"> <button class="btn btn-secondary" style="width:100%;margin-top:var(--space-5)" id="health-detail-edit">Bearbeiten</button>
<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>
`; `;
const modalTitle = entry.typ === 'gewicht' const modalTitle = entry.typ === 'gewicht'
@ -928,25 +925,6 @@ window.Page_health = (() => {
UI.modal.close(); UI.modal.close();
_showForm(entry, entry.typ); _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) { function _detailFields(e) {
@ -1031,8 +1009,13 @@ window.Page_health = (() => {
`; `;
const footer = ` const footer = `
<button type="button" class="btn btn-secondary flex-1" id="health-form-cancel">Abbrechen</button> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="health-form" class="btn btn-primary flex-1">${isEdit ? 'Speichern' : 'Erstellen'}</button> <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]; 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-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 => { form.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const btn = document.querySelector('[form="health-form"][type="submit"]') || form.querySelector('[type="submit"]'); const btn = document.querySelector('[form="health-form"][type="submit"]') || form.querySelector('[type="submit"]');

View file

@ -295,9 +295,8 @@ window.Page_places = (() => {
`; `;
const footer = isOwn ? ` 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-secondary" style="width:100%" id="place-detail-edit">Bearbeiten</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-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-edit">Bearbeiten</button>
` : ` ` : `
<button type="button" class="btn btn-primary flex-1" 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); _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 // Auf Karte zentrieren
if (_map) _map.setView([place.lat, place.lon], 15); if (_map) _map.setView([place.lat, place.lon], 15);
@ -415,16 +395,36 @@ window.Page_places = (() => {
`; `;
const footer = ` const footer = `
<button type="button" class="btn btn-secondary flex-1" id="place-form-cancel">Abbrechen</button> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="place-form" class="btn btn-primary flex-1"> <button type="submit" form="place-form" class="btn btn-primary" style="width:100%">
${isEdit ? 'Speichern' : 'Ort hinzufügen'} ${isEdit ? 'Speichern' : 'Ort hinzufügen'}
</button> </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 }); 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-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 // GPS-Button
document.getElementById('pf-gps-btn')?.addEventListener('click', async () => { document.getElementById('pf-gps-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('pf-gps-btn'); const btn = document.getElementById('pf-gps-btn');

View file

@ -316,8 +316,10 @@ window.Page_settings = (() => {
</form> </form>
`, `,
footer: ` footer: `
<button type="submit" form="profile-form" class="btn btn-primary">Speichern</button> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="button" class="btn btn-ghost" data-modal-close>Abbrechen</button> <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>
`, `,
}); });

View file

@ -399,10 +399,12 @@ window.Page_sitting = (() => {
</form> </form>
`; `;
const footer = ` const footer = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</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"> <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`} ${s ? `${UI.icon('floppy-disk')} Speichern` : `${UI.icon('plus')} Profil erstellen`}
</button> </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 }); UI.modal.open({ title: s ? 'Sitter-Profil bearbeiten' : 'Sitter-Profil erstellen', body, footer });

View file

@ -511,10 +511,12 @@ window.Page_walks = (() => {
`; `;
const footer = ` const footer = `
<button type="button" class="btn btn-secondary flex-1" id="wf-cancel">Abbrechen</button> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="walk-form" class="btn btn-primary flex-1"> <button type="submit" form="walk-form" class="btn btn-primary" style="width:100%">
${isEdit ? 'Speichern' : `${UI.icon('calendar-dots')} Treffen planen`} ${isEdit ? 'Speichern' : `${UI.icon('calendar-dots')} Treffen planen`}
</button> </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 }); UI.modal.open({ title: isEdit ? 'Treffen bearbeiten' : `${UI.icon('dog')} Treffen planen`, body, footer });

View file

@ -77,7 +77,7 @@ const UI = (() => {
overlay.querySelector('.modal-close-btn')?.addEventListener('click', close); overlay.querySelector('.modal-close-btn')?.addEventListener('click', close);
document.getElementById('modal-container').appendChild(overlay); document.getElementById('modal-container').appendChild(overlay);
document.body.style.overflow = 'hidden'; document.documentElement.classList.add('modal-open');
_current = { overlay, onClose }; _current = { overlay, onClose };
return overlay.querySelector('.modal'); return overlay.querySelector('.modal');
@ -85,10 +85,18 @@ const UI = (() => {
function close() { function close() {
if (!_current) return; if (!_current) return;
_current.onClose?.(); const { onClose } = _current;
onClose?.();
_current.overlay.remove(); _current.overlay.remove();
document.body.style.overflow = ''; document.documentElement.classList.remove('modal-open');
_current = null; _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 // Bestätigungsdialog

View file

@ -10,9 +10,11 @@
"lang": "de", "lang": "de",
"categories": ["lifestyle", "social"], "categories": ["lifestyle", "social"],
"icons": [ "icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-180.png", "sizes": "180x180", "type": "image/png" } { "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": { "share_target": {
"action": "/share", "action": "/share",

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v161'; const CACHE_VERSION = 'by-v193';
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