Chat: Bilder, Kamerafoto, Dateien und Sprachnachrichten

- Kundenbereich und Admin-Chat: Bueroklammer-Menue (Bild, Kamera, Datei),
  Mikrofon fuer Sprachnachrichten, Vorschau vor dem Senden mit
  Upload-Fortschritt, Einfuegen (Strg+V) und Hineinziehen von Dateien
- Kamera: am Handy/Tablet die Kamera-App, am Rechner die Kamera im Browser
  mit Vorschau, Kamerawechsel und "Nochmal"
- Sprachnachricht: Aufnahme mit Zeit und Pegel, vorher anhoerbar oder
  direkt senden, hoechstens 5 Minuten
- Verlauf: Bilder (einzeln bzw. Raster, Grossansicht), Player fuer
  Sprachnachrichten, Datei-Kacheln mit Download
- Server: jede Datei einzeln roh hochgeladen (max. 25 MB, 10 je Nachricht,
  max. 3 gleichzeitig), Typ aus dem Inhalt, nur erlaubte Endungen, keine
  Programme; Ablage unter upload-data/chat/<Kunde>/ ausserhalb von public/,
  Auslieferung nur mit Login; Bilder, Audio und PDF inline, alles andere
  als Download (nosniff, CSP sandbox)
- Nicht gesendete Anhaenge werden nach 24 Stunden geloescht
- Benachrichtigungs-Mail nennt nur Art und Namen der Anhaenge
- Appwrite: neues Attribut portalMessages.attachments
  (scripts/setup-chat-collection.mjs, vor dem Deploy ausfuehren)
- Datenschutzerklaerung Abschnitt 6 ergaenzt (Stand 26.09.2026), PDF neu

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-26 14:01:17 +00:00
1 parent 6e18a70a5d
commit 35d98da977
15 files changed
+2086 -107

No files matched your search

+32 -41
View File
@@ -517,16 +517,25 @@ const chatState = {
badgeTimer: null,
initialized: false,
viewAs: false,
shownIds: new Set(),
composer: null,
}
const chatFileUrl = (att) => `/api/chat/attachments/${encodeURIComponent(att.id)}`
function appendChatMessages(messages) {
const box = document.getElementById('chat-messages')
if (!box || !messages.length) return
for (const msg of messages) {
const div = document.createElement('div')
div.className = `chat-bubble ${msg.sender === 'customer' ? 'mine' : 'theirs'}`
div.innerHTML = `${escapeHtml(msg.text)}<span class="chat-meta">${msg.sender === 'customer' ? 'Du' : escapeHtml(msg.senderName || 'Webklar-Team')} · ${fmtDateTime(msg.createdAt)}</span>`
box.appendChild(div)
// Poll und Antwort auf das Senden koennen dieselbe Nachricht liefern
if (chatState.shownIds.has(msg.id)) continue
chatState.shownIds.add(msg.id)
const mine = msg.sender === 'customer'
box.appendChild(ChatComposer.renderMessage(msg, {
mine,
meta: `${mine ? 'Du' : msg.senderName || 'Webklar-Team'} · ${fmtDateTime(msg.createdAt)}`,
fileUrl: chatFileUrl,
}))
if (msg.createdAt > chatState.lastCreatedAt) chatState.lastCreatedAt = msg.createdAt
}
box.scrollTop = box.scrollHeight
@@ -562,47 +571,29 @@ function isChatTabActive() {
return Boolean(document.getElementById('view-chat')?.classList.contains('active'))
}
async function sendChatMessage() {
const input = document.getElementById('chat-input')
const btn = document.getElementById('chat-send')
const text = input.value.trim()
if (!text) return
btn.disabled = true
try {
const { message } = await api('/api/chat/messages', {
method: 'POST',
body: JSON.stringify({ text }),
})
input.value = ''
appendChatMessages([message])
} catch (err) {
alert(err.status === 403 ? err.message : 'Nachricht konnte nicht gesendet werden: ' + err.message)
} finally {
btn.disabled = false
input.focus()
}
}
async function initChat() {
if (chatState.initialized) return
chatState.initialized = true
const input = document.getElementById('chat-input')
const btn = document.getElementById('chat-send')
if (chatState.viewAs) {
input.disabled = true
btn.disabled = true
input.placeholder = 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.'
} else {
btn.addEventListener('click', sendChatMessage)
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendChatMessage()
}
})
}
ChatComposer.trackScroll(document.getElementById('chat-messages'))
// Text, Bilder, Kamerafoto, Dateien und Sprachnachrichten (public/chat-composer.js)
chatState.composer = ChatComposer.create({
root: document.getElementById('chat-composer'),
dropZone: document.querySelector('#view-chat .chat-box'),
uploadUrl: '/api/chat/attachments',
placeholder: 'Nachricht an das WEBklar-Team…',
placeholderShort: 'Nachricht…',
disabled: chatState.viewAs,
disabledText: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.',
send: async ({ text, attachmentIds }) => {
const { message } = await api('/api/chat/messages', {
method: 'POST',
body: JSON.stringify({ text, attachmentIds }),
})
return message
},
onSent: (message) => appendChatMessages([message]),
})
await pollChatMessages()
await api('/api/chat/read', { method: 'POST' }).catch(() => {})