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

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