diff --git a/backend/database.py b/backend/database.py index 7554627..6cd887f 100644 --- a/backend/database.py +++ b/backend/database.py @@ -453,6 +453,13 @@ def _migrate(conn_factory): ("users", "profil_sichtbarkeit", "TEXT NOT NULL DEFAULT 'public'"), ("users", "avatar_url", "TEXT"), ("places", "telefon", "TEXT"), + # Chat: Foto-Versand + ("direct_messages", "media_url", "TEXT"), + ("direct_messages", "media_type", "TEXT"), + # Chat: Read Receipts + ("direct_messages", "read_at", "TEXT"), + # Chat: Online-Indikator + ("users", "last_seen", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: @@ -648,3 +655,35 @@ def _migrate(conn_factory): CREATE INDEX IF NOT EXISTS idx_dog_shares_user ON dog_shares(shared_with_id); """) logger.info("Migration: dog_shares Tabelle bereit.") + + # Events: user_id NOT NULL Constraint entfernen (für Scheduler-Imports ohne User) + row = conn.execute( + "SELECT notnull FROM pragma_table_info('events') WHERE name='user_id'" + ).fetchone() + if row and row[0] == 1: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS events_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + titel TEXT NOT NULL, + datum TEXT NOT NULL, + uhrzeit TEXT, + lat REAL, + lon REAL, + ort_name TEXT, + typ TEXT NOT NULL DEFAULT 'sonstiges', + beschreibung TEXT, + link TEXT, + status TEXT NOT NULL DEFAULT 'aktiv', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + quelle TEXT NOT NULL DEFAULT 'nutzer', + external_id TEXT + ); + INSERT OR IGNORE INTO events_new SELECT * FROM events; + DROP TABLE events; + ALTER TABLE events_new RENAME TO events; + CREATE INDEX IF NOT EXISTS idx_events_datum ON events(datum ASC); + CREATE UNIQUE INDEX IF NOT EXISTS idx_events_external + ON events(external_id) WHERE external_id IS NOT NULL; + """) + logger.info("Migration: events.user_id NOT NULL Constraint entfernt.") diff --git a/backend/routes/chat.py b/backend/routes/chat.py index 30f9826..0874303 100644 --- a/backend/routes/chat.py +++ b/backend/routes/chat.py @@ -1,6 +1,9 @@ """BAN YARO — Direktnachrichten""" import logging -from fastapi import APIRouter, Depends, HTTPException +import os +import uuid +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from database import db from auth import get_current_user @@ -8,6 +11,12 @@ from auth import get_current_user router = APIRouter() logger = logging.getLogger(__name__) +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") +CHAT_DIR = os.path.join(MEDIA_DIR, "chat") +ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} +MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB +ONLINE_MINUTES = 3 + def _conv_key(a: int, b: int): """Normalisiert Konversations-User-IDs: user_a < user_b.""" @@ -17,11 +26,13 @@ def _conv_key(a: int, b: int): @router.get("/conversations") async def list_conversations(user=Depends(get_current_user)): uid = user["id"] + online_cutoff = (datetime.utcnow() - timedelta(minutes=ONLINE_MINUTES)).isoformat() with db() as conn: rows = conn.execute(""" SELECT c.id, c.last_msg_at, CASE WHEN c.user_a_id=? THEN c.user_b_id ELSE c.user_a_id END AS partner_id, CASE WHEN c.user_a_id=? THEN ub.name ELSE ua.name END AS partner_name, + CASE WHEN c.user_a_id=? THEN ub.last_seen ELSE ua.last_seen END AS partner_last_seen, (SELECT text FROM direct_messages WHERE conversation_id=c.id AND is_deleted=0 ORDER BY created_at DESC LIMIT 1) AS last_text, @@ -39,8 +50,14 @@ async def list_conversations(user=Depends(get_current_user)): JOIN users ub ON ub.id=c.user_b_id WHERE c.user_a_id=? OR c.user_b_id=? ORDER BY COALESCE(c.last_msg_at, c.created_at) DESC - """, (uid, uid, uid, uid, uid, uid)).fetchall() - return [dict(r) for r in rows] + """, (uid, uid, uid, uid, uid, uid, uid)).fetchall() + result = [] + for r in rows: + d = dict(r) + last_seen = d.get("partner_last_seen") + d["partner_online"] = bool(last_seen and last_seen >= online_cutoff) + result.append(d) + return result class StartConvModel(BaseModel): @@ -79,6 +96,8 @@ async def start_conversation(data: StartConvModel, user=Depends(get_current_user async def get_messages(conv_id: int, offset: int = 0, limit: int = 50, user=Depends(get_current_user)): uid = user["id"] + online_cutoff = (datetime.utcnow() - timedelta(minutes=ONLINE_MINUTES)).isoformat() + now_iso = datetime.utcnow().isoformat() with db() as conn: conv = conn.execute( "SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)", @@ -88,10 +107,20 @@ async def get_messages(conv_id: int, offset: int = 0, limit: int = 50, raise HTTPException(404, "Konversation nicht gefunden.") partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"] - partner = conn.execute("SELECT name FROM users WHERE id=?", (partner_id,)).fetchone() + partner = conn.execute( + "SELECT name, last_seen FROM users WHERE id=?", (partner_id,) + ).fetchone() + + # Ungelesene Nachrichten des Partners als gelesen markieren + conn.execute(""" + UPDATE direct_messages + SET read_at=? + WHERE conversation_id=? AND sender_id!=? AND read_at IS NULL + """, (now_iso, conv_id, uid)) msgs = conn.execute(""" SELECT m.id, m.sender_id, m.text, m.is_deleted, m.created_at, + m.media_url, m.media_type, m.read_at, u.name AS sender_name FROM direct_messages m JOIN users u ON u.id=m.sender_id @@ -100,10 +129,14 @@ async def get_messages(conv_id: int, offset: int = 0, limit: int = 50, LIMIT ? OFFSET ? """, (conv_id, limit, offset)).fetchall() + partner_last_seen = partner["last_seen"] if partner else None + partner_online = bool(partner_last_seen and partner_last_seen >= online_cutoff) + return { "conversation_id": conv_id, "partner_id": partner_id, "partner_name": partner["name"] if partner else "Unbekannt", + "partner_online": partner_online, "messages": [dict(m) for m in msgs], } @@ -157,6 +190,71 @@ async def send_message(conv_id: int, data: SendMsgModel, user=Depends(get_curren return {"id": msg_id, "ok": True} +@router.post("/conversations/{conv_id}/upload", status_code=201) +async def upload_photo(conv_id: int, file: UploadFile = File(...), + user=Depends(get_current_user)): + uid = user["id"] + if file.content_type not in ALLOWED_IMAGE_TYPES: + raise HTTPException(400, "Nur JPEG, PNG, GIF und WebP erlaubt.") + + data = await file.read() + if len(data) > MAX_UPLOAD_BYTES: + raise HTTPException(400, "Datei zu groß (max. 10 MB).") + + os.makedirs(CHAT_DIR, exist_ok=True) + ext = os.path.splitext(file.filename or "")[1] or ".jpg" + filename = f"{uuid.uuid4().hex}{ext}" + path = os.path.join(CHAT_DIR, filename) + with open(path, "wb") as f: + f.write(data) + media_url = f"/media/chat/{filename}" + media_type = file.content_type + + with db() as conn: + conv = conn.execute( + "SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)", + (conv_id, uid, uid) + ).fetchone() + if not conv: + raise HTTPException(404, "Konversation nicht gefunden.") + + partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"] + + cur = conn.execute(""" + INSERT INTO direct_messages (conversation_id, sender_id, text, media_url, media_type) + VALUES (?,?,?,?,?) + """, (conv_id, uid, "", media_url, media_type)) + msg_id = cur.lastrowid + + conn.execute( + "UPDATE conversations SET last_msg_at=datetime('now') WHERE id=?", + (conv_id,) + ) + + try: + from routes.push import send_push_to_user + send_push_to_user(partner_id, { + "title": f"Foto von {user['name']}", + "body": "hat dir ein Foto geschickt", + "type": "chat_message", + "tag": f"chat-{conv_id}", + "data": {"page": "chat", "conversation_id": conv_id}, + }) + except Exception: + pass + + return {"id": msg_id, "media_url": media_url, "media_type": media_type, "ok": True} + + +@router.post("/heartbeat") +async def heartbeat(user=Depends(get_current_user)): + uid = user["id"] + now_iso = datetime.utcnow().isoformat() + with db() as conn: + conn.execute("UPDATE users SET last_seen=? WHERE id=?", (now_iso, uid)) + return {"ok": True} + + @router.post("/conversations/{conv_id}/read") async def mark_read(conv_id: int, user=Depends(get_current_user)): uid = user["id"] diff --git a/backend/routes/health.py b/backend/routes/health.py index 125cfd8..7c26678 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -174,6 +174,34 @@ async def delete_health(dog_id: int, entry_id: int, user=Depends(get_current_use return None +# ------------------------------------------------------------------ +# DELETE /api/dogs/{dog_id}/health/{id}/dokument — Datei löschen +# ------------------------------------------------------------------ +@router.delete("/{dog_id}/health/{entry_id}/dokument") +async def delete_dokument(dog_id: int, entry_id: int, user=Depends(get_current_user)): + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + entry = conn.execute( + "SELECT datei_url FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id) + ).fetchone() + if not entry: + raise HTTPException(404, "Eintrag nicht gefunden.") + + datei_url = entry["datei_url"] + if datei_url: + # datei_url z.B. "/media/health/health_42_abc12345.pdf" + filename = datei_url.lstrip("/media/") + path = os.path.join(MEDIA_DIR, filename) + if os.path.isfile(path): + os.remove(path) + + conn.execute( + "UPDATE health SET datei_url=NULL, datei_typ=NULL WHERE id=?", (entry_id,) + ) + + return {"ok": True} + + # ------------------------------------------------------------------ # POST /api/dogs/{dog_id}/health/{id}/dokument — Datei-Upload # ------------------------------------------------------------------ diff --git a/backend/routes/routen.py b/backend/routes/routen.py index 1b5a60c..aaf44a3 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -37,7 +37,7 @@ class RouteCreate(BaseModel): untergrund: Optional[str] = None # wald | asphalt | wiese | mix schatten: Optional[bool] = None leine_empfohlen: Optional[bool] = None - is_public: Optional[bool] = True + is_public: Optional[bool] = False hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium class RouteUpdate(BaseModel): diff --git a/backend/scheduler.py b/backend/scheduler.py index 4304909..a404fc0 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -5,9 +5,12 @@ Täglich: Gesundheits-Erinnerungen per Push versenden. import logging from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger +_TZ = ZoneInfo("Europe/Berlin") + from database import db from routes.push import send_push_to_user, send_push_to_all import weather @@ -64,7 +67,7 @@ def start(): _scheduler.add_job( _job_import_events, 'date', - run_date=datetime.now() + timedelta(seconds=10), + run_date=datetime.now(tz=_TZ) + timedelta(seconds=10), id="import_events_startup", replace_existing=True, ) @@ -72,7 +75,7 @@ def start(): _scheduler.add_job( _job_prewarm_cities, 'date', - run_date=datetime.now() + timedelta(seconds=90), + run_date=datetime.now(tz=_TZ) + timedelta(seconds=90), id="prewarm_cities_startup", replace_existing=True, ) @@ -80,7 +83,7 @@ def start(): _scheduler.add_job( _job_seed_breeds, 'date', - run_date=datetime.now() + timedelta(seconds=15), + run_date=datetime.now(tz=_TZ) + timedelta(seconds=15), id="seed_breeds_startup", replace_existing=True, ) @@ -88,7 +91,7 @@ def start(): _scheduler.add_job( _job_seed_wikidata_breeds, 'date', - run_date=datetime.now() + timedelta(seconds=45), + run_date=datetime.now(tz=_TZ) + timedelta(seconds=45), id="seed_wikidata_startup", replace_existing=True, ) @@ -339,7 +342,7 @@ async def _job_import_events(): if not exists: conn.execute(""" INSERT INTO events (user_id, titel, datum, ort_name, typ, link, quelle, external_id, status) - VALUES (0, ?, ?, ?, ?, ?, 'vdh', ?, 'aktiv') + VALUES (NULL, ?, ?, ?, ?, ?, 'vdh', ?, 'aktiv') """, ( ev['titel'], ev['datum'], diff --git a/backend/scraper/wikidata_breeds.py b/backend/scraper/wikidata_breeds.py index 8cf0fc2..251b75f 100644 --- a/backend/scraper/wikidata_breeds.py +++ b/backend/scraper/wikidata_breeds.py @@ -165,10 +165,10 @@ async def mirror_wikidata_photos(): # Wikimedia Commons: append ?width=600 for scaled download fetch_url = img_url if "?" in img_url else img_url + "?width=600" - retries = 2 + retries = 4 for attempt in range(retries): try: - await asyncio.sleep(0.3) # 300ms zwischen Requests → ~3/s + await asyncio.sleep(1.0 * (attempt + 1)) # exponentiell: 1s, 2s, 3s, 4s r = await client.get(fetch_url) if r.status_code == 200 and r.headers.get("content-type", "").startswith("image"): with open(local_path, "wb") as f: diff --git a/backend/static/css/components.css b/backend/static/css/components.css index a62d998..f1f3d1e 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -1612,10 +1612,22 @@ textarea.form-control { } .rk-search-row { display: flex; + flex-wrap: wrap; gap: var(--space-2); margin-bottom: var(--space-3); align-items: center; } +.rk-search-row .rk-search { + min-width: 0; + flex-basis: 160px; +} +.rk-search-row .rk-view-toggle { + flex-shrink: 0; +} +@media (max-width: 480px) { + .rk-search-row .rk-search { flex: 1 1 100%; order: -1; } + .rk-search-row .rk-view-toggle { margin-left: auto; } +} /* Import-Label als Button */ .rk-imp-btn { cursor: pointer; @@ -4491,3 +4503,47 @@ textarea.form-control { } .chat-send-btn:hover { background: var(--c-primary-dark); } .chat-send-btn:disabled { opacity: 0.5; cursor: default; } + +/* Chat: Kamera-Button */ +.chat-photo-btn { + width: 36px; + height: 36px; + border-radius: 50%; + background: transparent; + color: var(--c-text-secondary); + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: color var(--transition-fast); +} +.chat-photo-btn:hover { color: var(--c-primary); } + +/* Chat: Bild in Nachrichtenblase */ +.chat-bubble-img { + max-width: 100%; + max-height: 260px; + border-radius: var(--radius-md); + display: block; + cursor: pointer; + object-fit: cover; +} + +/* Chat: Online-Dot */ +.online-dot { + width: 8px; + height: 8px; + background: #22C55E; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} + +.chat-avatar-dot { + position: absolute; + bottom: 1px; + right: 1px; + border: 2px solid var(--c-surface); +} diff --git a/backend/static/index.html b/backend/static/index.html index fe5c0a2..375aca8 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -22,8 +22,8 @@ - - + + @@ -289,9 +289,9 @@ - - - + + + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 3bde8cf..6c0abc5 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -126,6 +126,9 @@ const API = (() => { uploadDokument(dogId, id, formData) { return upload(`/dogs/${dogId}/health/${id}/dokument`, formData); }, + deleteDocument(dogId, id) { + return del(`/dogs/${dogId}/health/${id}/dokument`); + }, kiZusammenfassung(dogId) { return post(`/dogs/${dogId}/health/ki-zusammenfassung`); }, @@ -344,6 +347,12 @@ const API = (() => { send(convId, text) { return post(`/chat/conversations/${convId}/messages`, { text }); }, markRead(convId) { return post(`/chat/conversations/${convId}/read`, {}); }, deleteMessage(msgId) { return del(`/chat/messages/${msgId}`); }, + uploadPhoto(convId, file) { + const fd = new FormData(); + fd.append('file', file); + return upload(`/chat/conversations/${convId}/upload`, fd); + }, + heartbeat() { return post('/chat/heartbeat', {}); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/app.js b/backend/static/js/app.js index b1bea35..e4bb234 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '117'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '119'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/chat.js b/backend/static/js/pages/chat.js index bf992a5..634d27d 100644 --- a/backend/static/js/pages/chat.js +++ b/backend/static/js/pages/chat.js @@ -4,19 +4,26 @@ window.Page_chat = (() => { - let _container = null; - let _view = 'list'; // 'list' | 'thread' - let _convId = null; - let _partnerName = ''; - let _myId = null; - let _pollTimer = null; - let _lastMsgId = 0; + let _container = null; + let _view = 'list'; // 'list' | 'thread' + let _convId = null; + let _partnerName = ''; + let _myId = null; + let _pollTimer = null; + let _heartbeatTimer = null; + let _lastMsgId = 0; // ---------------------------------------------------------- async function init(container, appState, params = {}) { _container = container; _myId = appState?.user?.id || null; + // Heartbeat: alle 30s online-Status senden + API.chat.heartbeat().catch(() => {}); + _heartbeatTimer = setInterval(() => { + API.chat.heartbeat().catch(() => {}); + }, 30000); + if (params.conversation_id) { await _openThread(params.conversation_id); } else { @@ -66,17 +73,23 @@ window.Page_chat = (() => { } el.innerHTML = convs.map(c => { - const initials = (c.partner_name || '?')[0].toUpperCase(); - const preview = c.last_text + const initials = (c.partner_name || '?')[0].toUpperCase(); + const preview = c.last_text ? _esc(c.last_text.substring(0, 60)) + (c.last_text.length > 60 ? '…' : '') : 'Noch keine Nachrichten'; - const timeStr = c.last_msg_at ? _fmtTime(c.last_msg_at) : ''; - const badge = c.unread_count > 0 + const timeStr = c.last_msg_at ? _fmtTime(c.last_msg_at) : ''; + const badge = c.unread_count > 0 ? `${c.unread_count}` : ''; + const onlineDot = c.partner_online + ? `` + : ''; return `
-
${initials}
+
+
${initials}
+ ${onlineDot ? `` : ''} +
${_esc(c.partner_name)}
${preview}
@@ -110,7 +123,10 @@ window.Page_chat = (() => { -
?
+
+
?
+ +
@@ -119,6 +135,11 @@ window.Page_chat = (() => {
+ + ` : ''; + // Read receipt icon (nur für eigene Nachrichten) + const readIcon = isMine + ? (m.read_at + ? `` + : ``) + : ''; + + // Medieninhalt + let bubbleContent = ''; + if (m.media_url) { + bubbleContent += `Foto`; + } + if (m.text) { + bubbleContent += (m.media_url ? `
` : '') + + _esc(m.text) + + (m.media_url ? `
` : ''); + } + if (!bubbleContent) bubbleContent = _esc(m.text); + html += `
-
${_esc(m.text)}
+
${bubbleContent}
- ${timeStr} ${deleteBtn} + ${timeStr} ${readIcon} ${deleteBtn}
@@ -276,6 +322,24 @@ window.Page_chat = (() => { } } + // ---------------------------------------------------------- + async function _onPhotoSelected(input) { + const file = input.files && input.files[0]; + if (!file || !_convId) return; + input.value = ''; + + const btn = document.getElementById('chat-send-btn'); + if (btn) btn.disabled = true; + try { + await API.chat.uploadPhoto(_convId, file); + await _loadMessages(true); + } catch (e) { + UI.toast(e.message || 'Foto-Upload fehlgeschlagen', 'danger'); + } finally { + if (btn) btn.disabled = false; + } + } + // ---------------------------------------------------------- async function _updateChatBadge() { try { @@ -339,6 +403,7 @@ window.Page_chat = (() => { _openThread, _send, _deleteMsg, + _onPhotoSelected, }; })(); diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 6e321b4..2f36caa 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -741,11 +741,17 @@ window.Page_health = (() => {
${UI.time.format(e.datum + 'T00:00:00')}
${e.notiz ? `
${_esc(e.notiz)}
` : ''} ${hasFile - ? ` - ${isPdf ? ' PDF öffnen' : ' Bild öffnen'} - ` + ? `
+ + ${isPdf ? ' PDF öffnen' : ' Bild öffnen'} + + +
` : `Noch keine Datei hochgeladen`}
@@ -776,6 +782,28 @@ window.Page_health = (() => { if (p) _showPraxForm(p); }); }); + // Dokument löschen + content.querySelectorAll('[data-action="delete-dok"]').forEach(btn => { + btn.addEventListener('click', async () => { + const id = parseInt(btn.dataset.id); + const dogId = _appState.activeDog.id; + const ok = await UI.modal.confirm({ + title: 'Dokument löschen', + text: 'Die Datei wird unwiderruflich gelöscht.', + confirmText: 'Löschen', + danger: true, + }); + if (!ok) return; + await UI.asyncButton(btn, async () => { + await API.health.deleteDocument(dogId, id); + const list = _data[_activeTab] || []; + const entry = list.find(e => e.id === id); + if (entry) { entry.datei_url = null; entry.datei_typ = null; } + _renderTab(); + UI.toast.success('Dokument gelöscht.'); + }); + }); + }); // Praxis hinzufügen content.querySelector('[data-action="add-praxis"]') ?.addEventListener('click', () => _showPraxForm(null)); diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 8d913de..954be6c 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -640,6 +640,10 @@ window.Page_map = (() => { const newMarkers = pois.map(poi => _createOsmMarker(poi, layerKey, t)); cluster.addLayers(newMarkers); _layers[layerKey].push(...newMarkers); + // Sicherstellen dass der Cluster auf der Karte ist (kann durch vorherigen Toggle fehlen) + if (_visible[layerKey] !== false && _map && !_map.hasLayer(cluster)) { + cluster.addTo(_map); + } } // Phase 1: sofort DB-Daten zeigen (fast=true) @@ -953,6 +957,8 @@ window.Page_map = (() => { // Eigene Orte + Giftköder laden // ---------------------------------------------------------- async function _loadAll() { + // Falls Overpass-Job steckengeblieben: zurücksetzen + _overpassActive = false; // Cluster-Gruppen leeren (OSM-Marker) Object.values(_clusterGroups).forEach(cg => cg.clearLayers()); // Eigene-Orte-Marker direkt von Karte entfernen @@ -1440,7 +1446,7 @@ window.Page_map = (() => {
diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index 4216836..16da03c 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -1030,7 +1030,7 @@ window.Page_routes = (() => { Viel Schatten
diff --git a/backend/static/sw.js b/backend/static/sw.js index 725e55b..bda238c 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v144'; +const CACHE_VERSION = 'by-v146'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten