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"""
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

View file

@ -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":

View file

@ -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);

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
</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),

View file

@ -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}`);
},
};
// ----------------------------------------------------------

View file

@ -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) {

View file

@ -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.');
}

View file

@ -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>
`;

View file

@ -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();

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">`)
: ''}
</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"]');

View file

@ -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');

View file

@ -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>
`,
});

View file

@ -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 });

View file

@ -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 });

View file

@ -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

View file

@ -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",

View file

@ -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