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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,8 +22,8 @@
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/layout.css?v=86">
<link rel="stylesheet" href="/css/components.css?v=86">
<link rel="stylesheet" href="/css/layout.css?v=87">
<link rel="stylesheet" href="/css/components.css?v=87">
</head>
<body>
@ -289,9 +289,9 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=91"></script>
<script src="/js/ui.js?v=91"></script>
<script src="/js/app.js?v=91"></script>
<script src="/js/api.js?v=92"></script>
<script src="/js/ui.js?v=92"></script>
<script src="/js/app.js?v=92"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

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

View file

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

View file

@ -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 ? '…' : '')
: '<em style="opacity:0.6">Noch keine Nachrichten</em>';
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
? `<span class="chat-unread-badge">${c.unread_count}</span>`
: '';
const onlineDot = c.partner_online
? `<span class="online-dot" title="Online"></span>`
: '';
return `
<div class="chat-conv-item" onclick="Page_chat._openThread(${c.id})">
<div class="chat-conv-avatar">${initials}</div>
<div style="position:relative;flex-shrink:0">
<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-name">${_esc(c.partner_name)}</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)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
</button>
<div class="chat-conv-avatar" id="chat-partner-av" style="width:32px;height:32px;font-size:var(--text-sm)">?</div>
<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>
<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>
</div>
<div class="chat-messages" id="chat-messages">
@ -119,6 +135,11 @@ window.Page_chat = (() => {
</div>
</div>
<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"
placeholder="Nachricht…" maxlength="2000"></textarea>
<button class="chat-send-btn" id="chat-send-btn" onclick="Page_chat._send()">
@ -160,10 +181,12 @@ window.Page_chat = (() => {
const data = await API.chat.messages(_convId);
_partnerName = data.partner_name;
const nameEl = document.getElementById('chat-partner-name');
const avEl = document.getElementById('chat-partner-av');
const nameEl = document.getElementById('chat-partner-name');
const avEl = document.getElementById('chat-partner-av');
const dotEl = document.getElementById('chat-partner-dot');
if (nameEl) nameEl.textContent = data.partner_name;
if (avEl) avEl.textContent = (data.partner_name || '?')[0].toUpperCase();
if (dotEl) dotEl.style.display = data.partner_online ? '' : 'none';
if (!data.messages.length) {
el.innerHTML = `
@ -200,6 +223,10 @@ window.Page_chat = (() => {
el.innerHTML = _renderMessages(data.messages);
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 _updateChatBadge();
} catch (e) {
@ -230,12 +257,31 @@ window.Page_chat = (() => {
</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 += `
<div class="chat-bubble-row ${rowClass}">
<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'}">
${timeStr} ${deleteBtn}
${timeStr} ${readIcon} ${deleteBtn}
</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() {
try {
@ -339,6 +403,7 @@ window.Page_chat = (() => {
_openThread,
_send,
_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>
${e.notiz ? `<div class="health-card-note">${_esc(e.notiz)}</div>` : ''}
${hasFile
? `<a href="${e.datei_url}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" style="margin-top:var(--space-2);display:inline-flex"
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'}
</a>`
? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);align-items:center;flex-wrap:wrap">
<a href="${e.datei_url}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" style="display:inline-flex"
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'}
</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>`}
</div>
</div>
@ -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));

View file

@ -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 = (() => {
</div>
<div class="form-group">
<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>
</div>
<div class="form-group">

View file

@ -1030,7 +1030,7 @@ window.Page_routes = (() => {
<input type="checkbox" id="ri-schatten"> Viel Schatten
</label>
<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>
</div>
</form>

View file

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