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:
rene 2026-04-17 22:38:33 +02:00
parent 34f29f9d0a
commit a7753c9cf5
15 changed files with 375 additions and 43 deletions

View file

@ -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.")

View file

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

View file

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

View file

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

View file

@ -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'],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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