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
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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', {}); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 = (() => {
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue