Sprint 16: Chat-Fotos/Online/Read-Receipts, Gesundheit-Dokumente löschen, Bugfixes
- Chat: Foto-Versand (POST /api/chat/conversations/{id}/upload, media_url/media_type)
- Chat: Online-Indikator (last_seen Heartbeat, grüner Dot, 3min-Fenster)
- Chat: Read Receipts (read_at, Einzel-/Doppelhaken-Icons)
- Gesundheit: Dokument löschen (DELETE .../dokument, Datei + DB-Eintrag)
- Bug: events.user_id NOT NULL → nullable (Table-Recreation-Migration)
- Bug: scheduler INSERT user_id 0 → NULL
- Bug: Wikidata Rate-Limit: sleep 0.3s→1.0s, retries 2→4, exponentielles Backoff
- SW: by-v146, APP_VER 119
This commit is contained in:
parent
34f29f9d0a
commit
a7753c9cf5
15 changed files with 375 additions and 43 deletions
|
|
@ -453,6 +453,13 @@ def _migrate(conn_factory):
|
||||||
("users", "profil_sichtbarkeit", "TEXT NOT NULL DEFAULT 'public'"),
|
("users", "profil_sichtbarkeit", "TEXT NOT NULL DEFAULT 'public'"),
|
||||||
("users", "avatar_url", "TEXT"),
|
("users", "avatar_url", "TEXT"),
|
||||||
("places", "telefon", "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:
|
with conn_factory() as conn:
|
||||||
for table, column, col_type in migrations:
|
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);
|
CREATE INDEX IF NOT EXISTS idx_dog_shares_user ON dog_shares(shared_with_id);
|
||||||
""")
|
""")
|
||||||
logger.info("Migration: dog_shares Tabelle bereit.")
|
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.")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
"""BAN YARO — Direktnachrichten"""
|
"""BAN YARO — Direktnachrichten"""
|
||||||
import logging
|
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 pydantic import BaseModel
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
|
|
@ -8,6 +11,12 @@ from auth import get_current_user
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
def _conv_key(a: int, b: int):
|
||||||
"""Normalisiert Konversations-User-IDs: user_a < user_b."""
|
"""Normalisiert Konversations-User-IDs: user_a < user_b."""
|
||||||
|
|
@ -17,11 +26,13 @@ def _conv_key(a: int, b: int):
|
||||||
@router.get("/conversations")
|
@router.get("/conversations")
|
||||||
async def list_conversations(user=Depends(get_current_user)):
|
async def list_conversations(user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
|
online_cutoff = (datetime.utcnow() - timedelta(minutes=ONLINE_MINUTES)).isoformat()
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT c.id, c.last_msg_at,
|
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 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.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
|
(SELECT text FROM direct_messages
|
||||||
WHERE conversation_id=c.id AND is_deleted=0
|
WHERE conversation_id=c.id AND is_deleted=0
|
||||||
ORDER BY created_at DESC LIMIT 1) AS last_text,
|
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
|
JOIN users ub ON ub.id=c.user_b_id
|
||||||
WHERE c.user_a_id=? OR 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
|
ORDER BY COALESCE(c.last_msg_at, c.created_at) DESC
|
||||||
""", (uid, uid, uid, uid, uid, uid)).fetchall()
|
""", (uid, uid, uid, uid, uid, uid, uid)).fetchall()
|
||||||
return [dict(r) for r in rows]
|
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):
|
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,
|
async def get_messages(conv_id: int, offset: int = 0, limit: int = 50,
|
||||||
user=Depends(get_current_user)):
|
user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
|
online_cutoff = (datetime.utcnow() - timedelta(minutes=ONLINE_MINUTES)).isoformat()
|
||||||
|
now_iso = datetime.utcnow().isoformat()
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
conv = conn.execute(
|
conv = conn.execute(
|
||||||
"SELECT * FROM conversations WHERE id=? AND (user_a_id=? OR user_b_id=?)",
|
"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.")
|
raise HTTPException(404, "Konversation nicht gefunden.")
|
||||||
|
|
||||||
partner_id = conv["user_b_id"] if conv["user_a_id"] == uid else conv["user_a_id"]
|
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("""
|
msgs = conn.execute("""
|
||||||
SELECT m.id, m.sender_id, m.text, m.is_deleted, m.created_at,
|
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
|
u.name AS sender_name
|
||||||
FROM direct_messages m
|
FROM direct_messages m
|
||||||
JOIN users u ON u.id=m.sender_id
|
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 ?
|
LIMIT ? OFFSET ?
|
||||||
""", (conv_id, limit, offset)).fetchall()
|
""", (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 {
|
return {
|
||||||
"conversation_id": conv_id,
|
"conversation_id": conv_id,
|
||||||
"partner_id": partner_id,
|
"partner_id": partner_id,
|
||||||
"partner_name": partner["name"] if partner else "Unbekannt",
|
"partner_name": partner["name"] if partner else "Unbekannt",
|
||||||
|
"partner_online": partner_online,
|
||||||
"messages": [dict(m) for m in msgs],
|
"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}
|
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")
|
@router.post("/conversations/{conv_id}/read")
|
||||||
async def mark_read(conv_id: int, user=Depends(get_current_user)):
|
async def mark_read(conv_id: int, user=Depends(get_current_user)):
|
||||||
uid = user["id"]
|
uid = user["id"]
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,34 @@ async def delete_health(dog_id: int, entry_id: int, user=Depends(get_current_use
|
||||||
return None
|
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
|
# POST /api/dogs/{dog_id}/health/{id}/dokument — Datei-Upload
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class RouteCreate(BaseModel):
|
||||||
untergrund: Optional[str] = None # wald | asphalt | wiese | mix
|
untergrund: Optional[str] = None # wald | asphalt | wiese | mix
|
||||||
schatten: Optional[bool] = None
|
schatten: Optional[bool] = None
|
||||||
leine_empfohlen: 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
|
hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium
|
||||||
|
|
||||||
class RouteUpdate(BaseModel):
|
class RouteUpdate(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,12 @@ Täglich: Gesundheits-Erinnerungen per Push versenden.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
_TZ = ZoneInfo("Europe/Berlin")
|
||||||
|
|
||||||
from database import db
|
from database import db
|
||||||
from routes.push import send_push_to_user, send_push_to_all
|
from routes.push import send_push_to_user, send_push_to_all
|
||||||
import weather
|
import weather
|
||||||
|
|
@ -64,7 +67,7 @@ def start():
|
||||||
_scheduler.add_job(
|
_scheduler.add_job(
|
||||||
_job_import_events,
|
_job_import_events,
|
||||||
'date',
|
'date',
|
||||||
run_date=datetime.now() + timedelta(seconds=10),
|
run_date=datetime.now(tz=_TZ) + timedelta(seconds=10),
|
||||||
id="import_events_startup",
|
id="import_events_startup",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
@ -72,7 +75,7 @@ def start():
|
||||||
_scheduler.add_job(
|
_scheduler.add_job(
|
||||||
_job_prewarm_cities,
|
_job_prewarm_cities,
|
||||||
'date',
|
'date',
|
||||||
run_date=datetime.now() + timedelta(seconds=90),
|
run_date=datetime.now(tz=_TZ) + timedelta(seconds=90),
|
||||||
id="prewarm_cities_startup",
|
id="prewarm_cities_startup",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
@ -80,7 +83,7 @@ def start():
|
||||||
_scheduler.add_job(
|
_scheduler.add_job(
|
||||||
_job_seed_breeds,
|
_job_seed_breeds,
|
||||||
'date',
|
'date',
|
||||||
run_date=datetime.now() + timedelta(seconds=15),
|
run_date=datetime.now(tz=_TZ) + timedelta(seconds=15),
|
||||||
id="seed_breeds_startup",
|
id="seed_breeds_startup",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
@ -88,7 +91,7 @@ def start():
|
||||||
_scheduler.add_job(
|
_scheduler.add_job(
|
||||||
_job_seed_wikidata_breeds,
|
_job_seed_wikidata_breeds,
|
||||||
'date',
|
'date',
|
||||||
run_date=datetime.now() + timedelta(seconds=45),
|
run_date=datetime.now(tz=_TZ) + timedelta(seconds=45),
|
||||||
id="seed_wikidata_startup",
|
id="seed_wikidata_startup",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
)
|
)
|
||||||
|
|
@ -339,7 +342,7 @@ async def _job_import_events():
|
||||||
if not exists:
|
if not exists:
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO events (user_id, titel, datum, ort_name, typ, link, quelle, external_id, status)
|
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['titel'],
|
||||||
ev['datum'],
|
ev['datum'],
|
||||||
|
|
|
||||||
|
|
@ -165,10 +165,10 @@ async def mirror_wikidata_photos():
|
||||||
|
|
||||||
# Wikimedia Commons: append ?width=600 for scaled download
|
# Wikimedia Commons: append ?width=600 for scaled download
|
||||||
fetch_url = img_url if "?" in img_url else img_url + "?width=600"
|
fetch_url = img_url if "?" in img_url else img_url + "?width=600"
|
||||||
retries = 2
|
retries = 4
|
||||||
for attempt in range(retries):
|
for attempt in range(retries):
|
||||||
try:
|
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)
|
r = await client.get(fetch_url)
|
||||||
if r.status_code == 200 and r.headers.get("content-type", "").startswith("image"):
|
if r.status_code == 200 and r.headers.get("content-type", "").startswith("image"):
|
||||||
with open(local_path, "wb") as f:
|
with open(local_path, "wb") as f:
|
||||||
|
|
|
||||||
|
|
@ -1612,10 +1612,22 @@ textarea.form-control {
|
||||||
}
|
}
|
||||||
.rk-search-row {
|
.rk-search-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
align-items: center;
|
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 */
|
/* Import-Label als Button */
|
||||||
.rk-imp-btn {
|
.rk-imp-btn {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -4491,3 +4503,47 @@ textarea.form-control {
|
||||||
}
|
}
|
||||||
.chat-send-btn:hover { background: var(--c-primary-dark); }
|
.chat-send-btn:hover { background: var(--c-primary-dark); }
|
||||||
.chat-send-btn:disabled { opacity: 0.5; cursor: default; }
|
.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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@
|
||||||
|
|
||||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||||
<link rel="stylesheet" href="/css/design-system.css">
|
<link rel="stylesheet" href="/css/design-system.css">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=86">
|
<link rel="stylesheet" href="/css/layout.css?v=87">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=86">
|
<link rel="stylesheet" href="/css/components.css?v=87">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -289,9 +289,9 @@
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
|
|
||||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||||
<script src="/js/api.js?v=91"></script>
|
<script src="/js/api.js?v=92"></script>
|
||||||
<script src="/js/ui.js?v=91"></script>
|
<script src="/js/ui.js?v=92"></script>
|
||||||
<script src="/js/app.js?v=91"></script>
|
<script src="/js/app.js?v=92"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,9 @@ const API = (() => {
|
||||||
uploadDokument(dogId, id, formData) {
|
uploadDokument(dogId, id, formData) {
|
||||||
return upload(`/dogs/${dogId}/health/${id}/dokument`, formData);
|
return upload(`/dogs/${dogId}/health/${id}/dokument`, formData);
|
||||||
},
|
},
|
||||||
|
deleteDocument(dogId, id) {
|
||||||
|
return del(`/dogs/${dogId}/health/${id}/dokument`);
|
||||||
|
},
|
||||||
kiZusammenfassung(dogId) {
|
kiZusammenfassung(dogId) {
|
||||||
return post(`/dogs/${dogId}/health/ki-zusammenfassung`);
|
return post(`/dogs/${dogId}/health/ki-zusammenfassung`);
|
||||||
},
|
},
|
||||||
|
|
@ -344,6 +347,12 @@ const API = (() => {
|
||||||
send(convId, text) { return post(`/chat/conversations/${convId}/messages`, { text }); },
|
send(convId, text) { return post(`/chat/conversations/${convId}/messages`, { text }); },
|
||||||
markRead(convId) { return post(`/chat/conversations/${convId}/read`, {}); },
|
markRead(convId) { return post(`/chat/conversations/${convId}/read`, {}); },
|
||||||
deleteMessage(msgId) { return del(`/chat/messages/${msgId}`); },
|
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', {}); },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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 = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ window.Page_chat = (() => {
|
||||||
let _partnerName = '';
|
let _partnerName = '';
|
||||||
let _myId = null;
|
let _myId = null;
|
||||||
let _pollTimer = null;
|
let _pollTimer = null;
|
||||||
|
let _heartbeatTimer = null;
|
||||||
let _lastMsgId = 0;
|
let _lastMsgId = 0;
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -17,6 +18,12 @@ window.Page_chat = (() => {
|
||||||
_container = container;
|
_container = container;
|
||||||
_myId = appState?.user?.id || null;
|
_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) {
|
if (params.conversation_id) {
|
||||||
await _openThread(params.conversation_id);
|
await _openThread(params.conversation_id);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -74,9 +81,15 @@ window.Page_chat = (() => {
|
||||||
const badge = c.unread_count > 0
|
const badge = c.unread_count > 0
|
||||||
? `<span class="chat-unread-badge">${c.unread_count}</span>`
|
? `<span class="chat-unread-badge">${c.unread_count}</span>`
|
||||||
: '';
|
: '';
|
||||||
|
const onlineDot = c.partner_online
|
||||||
|
? `<span class="online-dot" title="Online"></span>`
|
||||||
|
: '';
|
||||||
return `
|
return `
|
||||||
<div class="chat-conv-item" onclick="Page_chat._openThread(${c.id})">
|
<div class="chat-conv-item" onclick="Page_chat._openThread(${c.id})">
|
||||||
|
<div style="position:relative;flex-shrink:0">
|
||||||
<div class="chat-conv-avatar">${initials}</div>
|
<div class="chat-conv-avatar">${initials}</div>
|
||||||
|
${onlineDot ? `<span class="online-dot chat-avatar-dot"></span>` : ''}
|
||||||
|
</div>
|
||||||
<div class="chat-conv-info">
|
<div class="chat-conv-info">
|
||||||
<div class="chat-conv-name">${_esc(c.partner_name)}</div>
|
<div class="chat-conv-name">${_esc(c.partner_name)}</div>
|
||||||
<div class="chat-conv-preview">${preview}</div>
|
<div class="chat-conv-preview">${preview}</div>
|
||||||
|
|
@ -110,7 +123,10 @@ window.Page_chat = (() => {
|
||||||
<button class="btn btn-ghost btn-sm" onclick="Page_chat._showList()" style="padding:var(--space-1)">
|
<button class="btn btn-ghost btn-sm" onclick="Page_chat._showList()" style="padding:var(--space-1)">
|
||||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
|
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<div style="position:relative;flex-shrink:0">
|
||||||
<div class="chat-conv-avatar" id="chat-partner-av" style="width:32px;height:32px;font-size:var(--text-sm)">?</div>
|
<div class="chat-conv-avatar" id="chat-partner-av" style="width:32px;height:32px;font-size:var(--text-sm)">?</div>
|
||||||
|
<span class="online-dot chat-avatar-dot" id="chat-partner-dot" style="display:none"></span>
|
||||||
|
</div>
|
||||||
<span class="chat-thread-partner" id="chat-partner-name">…</span>
|
<span class="chat-thread-partner" id="chat-partner-name">…</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-messages" id="chat-messages">
|
<div class="chat-messages" id="chat-messages">
|
||||||
|
|
@ -119,6 +135,11 @@ window.Page_chat = (() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-input-bar">
|
<div class="chat-input-bar">
|
||||||
|
<input type="file" id="chat-photo-input" accept="image/*" style="display:none"
|
||||||
|
onchange="Page_chat._onPhotoSelected(this)">
|
||||||
|
<button class="chat-photo-btn" onclick="document.getElementById('chat-photo-input').click()" title="Foto senden">
|
||||||
|
<svg class="ph-icon"><use href="/icons/phosphor.svg#camera"></use></svg>
|
||||||
|
</button>
|
||||||
<textarea id="chat-input" class="chat-input" rows="1"
|
<textarea id="chat-input" class="chat-input" rows="1"
|
||||||
placeholder="Nachricht…" maxlength="2000"></textarea>
|
placeholder="Nachricht…" maxlength="2000"></textarea>
|
||||||
<button class="chat-send-btn" id="chat-send-btn" onclick="Page_chat._send()">
|
<button class="chat-send-btn" id="chat-send-btn" onclick="Page_chat._send()">
|
||||||
|
|
@ -162,8 +183,10 @@ window.Page_chat = (() => {
|
||||||
|
|
||||||
const nameEl = document.getElementById('chat-partner-name');
|
const nameEl = document.getElementById('chat-partner-name');
|
||||||
const avEl = document.getElementById('chat-partner-av');
|
const avEl = document.getElementById('chat-partner-av');
|
||||||
|
const dotEl = document.getElementById('chat-partner-dot');
|
||||||
if (nameEl) nameEl.textContent = data.partner_name;
|
if (nameEl) nameEl.textContent = data.partner_name;
|
||||||
if (avEl) avEl.textContent = (data.partner_name || '?')[0].toUpperCase();
|
if (avEl) avEl.textContent = (data.partner_name || '?')[0].toUpperCase();
|
||||||
|
if (dotEl) dotEl.style.display = data.partner_online ? '' : 'none';
|
||||||
|
|
||||||
if (!data.messages.length) {
|
if (!data.messages.length) {
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
|
|
@ -200,6 +223,10 @@ window.Page_chat = (() => {
|
||||||
el.innerHTML = _renderMessages(data.messages);
|
el.innerHTML = _renderMessages(data.messages);
|
||||||
if (wasAtBottom) _scrollToBottom(el);
|
if (wasAtBottom) _scrollToBottom(el);
|
||||||
|
|
||||||
|
// Online-Dot aktualisieren
|
||||||
|
const dotEl = document.getElementById('chat-partner-dot');
|
||||||
|
if (dotEl) dotEl.style.display = data.partner_online ? '' : 'none';
|
||||||
|
|
||||||
await API.chat.markRead(_convId).catch(() => {});
|
await API.chat.markRead(_convId).catch(() => {});
|
||||||
await _updateChatBadge();
|
await _updateChatBadge();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -230,12 +257,31 @@ window.Page_chat = (() => {
|
||||||
</button>`
|
</button>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// Read receipt icon (nur für eigene Nachrichten)
|
||||||
|
const readIcon = isMine
|
||||||
|
? (m.read_at
|
||||||
|
? `<svg class="ph-icon" style="width:12px;height:12px;color:var(--c-primary)"><use href="/icons/phosphor.svg#checks"></use></svg>`
|
||||||
|
: `<svg class="ph-icon" style="width:12px;height:12px;opacity:0.5"><use href="/icons/phosphor.svg#check"></use></svg>`)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Medieninhalt
|
||||||
|
let bubbleContent = '';
|
||||||
|
if (m.media_url) {
|
||||||
|
bubbleContent += `<img src="${UI.escape(m.media_url)}" class="chat-bubble-img" alt="Foto" onclick="window.open('${UI.escape(m.media_url)}','_blank')">`;
|
||||||
|
}
|
||||||
|
if (m.text) {
|
||||||
|
bubbleContent += (m.media_url ? `<div style="margin-top:var(--space-1)">` : '') +
|
||||||
|
_esc(m.text) +
|
||||||
|
(m.media_url ? `</div>` : '');
|
||||||
|
}
|
||||||
|
if (!bubbleContent) bubbleContent = _esc(m.text);
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="chat-bubble-row ${rowClass}">
|
<div class="chat-bubble-row ${rowClass}">
|
||||||
<div>
|
<div>
|
||||||
<div class="chat-bubble ${bubClass}${delClass}">${_esc(m.text)}</div>
|
<div class="chat-bubble ${bubClass}${delClass}">${bubbleContent}</div>
|
||||||
<div class="chat-bubble-time" style="display:flex;align-items:center;gap:2px;justify-content:${isMine ? 'flex-end' : 'flex-start'}">
|
<div class="chat-bubble-time" style="display:flex;align-items:center;gap:2px;justify-content:${isMine ? 'flex-end' : 'flex-start'}">
|
||||||
${timeStr} ${deleteBtn}
|
${timeStr} ${readIcon} ${deleteBtn}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -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() {
|
async function _updateChatBadge() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -339,6 +403,7 @@ window.Page_chat = (() => {
|
||||||
_openThread,
|
_openThread,
|
||||||
_send,
|
_send,
|
||||||
_deleteMsg,
|
_deleteMsg,
|
||||||
|
_onPhotoSelected,
|
||||||
};
|
};
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -741,11 +741,17 @@ window.Page_health = (() => {
|
||||||
<div class="health-card-meta">${UI.time.format(e.datum + 'T00:00:00')}</div>
|
<div class="health-card-meta">${UI.time.format(e.datum + 'T00:00:00')}</div>
|
||||||
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
|
||||||
${hasFile
|
${hasFile
|
||||||
? `<a href="${e.datei_url}" target="_blank" rel="noopener"
|
? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);align-items:center;flex-wrap:wrap">
|
||||||
class="btn btn-secondary btn-sm" style="margin-top:var(--space-2);display:inline-flex"
|
<a href="${e.datei_url}" target="_blank" rel="noopener"
|
||||||
|
class="btn btn-secondary btn-sm" style="display:inline-flex"
|
||||||
onclick="event.stopPropagation()">
|
onclick="event.stopPropagation()">
|
||||||
${isPdf ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'}
|
${isPdf ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen' : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'}
|
||||||
</a>`
|
</a>
|
||||||
|
<button class="btn btn-danger btn-sm" data-action="delete-dok" data-id="${e.id}"
|
||||||
|
onclick="event.stopPropagation()" aria-label="Dokument löschen">
|
||||||
|
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>`
|
||||||
: `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch keine Datei hochgeladen</span>`}
|
: `<span style="font-size:var(--text-xs);color:var(--c-text-muted)">Noch keine Datei hochgeladen</span>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -776,6 +782,28 @@ window.Page_health = (() => {
|
||||||
if (p) _showPraxForm(p);
|
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
|
// Praxis hinzufügen
|
||||||
content.querySelector('[data-action="add-praxis"]')
|
content.querySelector('[data-action="add-praxis"]')
|
||||||
?.addEventListener('click', () => _showPraxForm(null));
|
?.addEventListener('click', () => _showPraxForm(null));
|
||||||
|
|
|
||||||
|
|
@ -640,6 +640,10 @@ window.Page_map = (() => {
|
||||||
const newMarkers = pois.map(poi => _createOsmMarker(poi, layerKey, t));
|
const newMarkers = pois.map(poi => _createOsmMarker(poi, layerKey, t));
|
||||||
cluster.addLayers(newMarkers);
|
cluster.addLayers(newMarkers);
|
||||||
_layers[layerKey].push(...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)
|
// Phase 1: sofort DB-Daten zeigen (fast=true)
|
||||||
|
|
@ -953,6 +957,8 @@ window.Page_map = (() => {
|
||||||
// Eigene Orte + Giftköder laden
|
// Eigene Orte + Giftköder laden
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
async function _loadAll() {
|
async function _loadAll() {
|
||||||
|
// Falls Overpass-Job steckengeblieben: zurücksetzen
|
||||||
|
_overpassActive = false;
|
||||||
// Cluster-Gruppen leeren (OSM-Marker)
|
// Cluster-Gruppen leeren (OSM-Marker)
|
||||||
Object.values(_clusterGroups).forEach(cg => cg.clearLayers());
|
Object.values(_clusterGroups).forEach(cg => cg.clearLayers());
|
||||||
// Eigene-Orte-Marker direkt von Karte entfernen
|
// Eigene-Orte-Marker direkt von Karte entfernen
|
||||||
|
|
@ -1440,7 +1446,7 @@ window.Page_map = (() => {
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||||||
<input type="checkbox" name="is_public" checked> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#globe"></use></svg> Öffentlich (von allen sichtbar)
|
<input type="checkbox" name="is_public"> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#globe"></use></svg> Öffentlich (von allen sichtbar)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
||||||
|
|
@ -1030,7 +1030,7 @@ window.Page_routes = (() => {
|
||||||
<input type="checkbox" id="ri-schatten"> Viel Schatten
|
<input type="checkbox" id="ri-schatten"> Viel Schatten
|
||||||
</label>
|
</label>
|
||||||
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
|
||||||
<input type="checkbox" id="ri-public" checked> Öffentlich
|
<input type="checkbox" id="ri-public"> Öffentlich
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v144';
|
const CACHE_VERSION = 'by-v146';
|
||||||
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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue