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

+40 -37
View File
@@ -269,8 +269,12 @@ const adminChat = {
activeCustomerName: '',
lastCreatedAt: '',
threadTimer: null,
shownIds: new Set(),
composer: null,
}
const adminChatUrl = (customerId) => `/api/portal-admin/chats/${encodeURIComponent(customerId)}`
function updateAdminChatBadge(unread) {
const badge = document.getElementById('admin-chat-badge')
if (!badge) return
@@ -319,11 +323,16 @@ async function loadChatList() {
function appendAdminChatMessages(messages) {
const box = document.getElementById('admin-chat-messages')
if (!box || !messages.length) return
const base = adminChatUrl(adminChat.activeCustomerId)
for (const msg of messages) {
const div = document.createElement('div')
div.className = `chat-bubble ${msg.sender === 'admin' ? 'mine' : 'theirs'}`
div.innerHTML = `${escapeHtml(msg.text)}<span class="chat-meta">${msg.sender === 'admin' ? escapeHtml(msg.senderName || 'Admin') : escapeHtml(adminChat.activeCustomerName)} · ${fmtDateTime(msg.createdAt)}</span>`
box.appendChild(div)
if (adminChat.shownIds.has(msg.id)) continue
adminChat.shownIds.add(msg.id)
const mine = msg.sender === 'admin'
box.appendChild(ChatComposer.renderMessage(msg, {
mine,
meta: `${mine ? msg.senderName || 'Admin' : adminChat.activeCustomerName} · ${fmtDateTime(msg.createdAt)}`,
fileUrl: (att) => `${base}/attachments/${encodeURIComponent(att.id)}`,
}))
if (msg.createdAt > adminChat.lastCreatedAt) adminChat.lastCreatedAt = msg.createdAt
}
box.scrollTop = box.scrollHeight
@@ -332,8 +341,10 @@ function appendAdminChatMessages(messages) {
async function pollAdminThread() {
if (!adminChat.activeCustomerId || document.hidden) return
try {
const customerId = adminChat.activeCustomerId
const q = adminChat.lastCreatedAt ? `?since=${encodeURIComponent(adminChat.lastCreatedAt)}` : ''
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/messages${q}`)
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages${q}`)
if (customerId !== adminChat.activeCustomerId) return // inzwischen andere Unterhaltung geoeffnet
if (messages.length) {
appendAdminChatMessages(messages)
await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/read`, { method: 'POST' }).catch(() => {})
@@ -343,52 +354,44 @@ async function pollAdminThread() {
}
}
async function sendAdminChatMessage() {
const input = document.getElementById('admin-chat-input')
const btn = document.getElementById('admin-chat-send')
const text = input.value.trim()
if (!text || !adminChat.activeCustomerId) return
btn.disabled = true
try {
const { message } = await api(`/api/portal-admin/chats/${encodeURIComponent(adminChat.activeCustomerId)}/messages`, {
method: 'POST',
body: JSON.stringify({ text }),
})
input.value = ''
appendAdminChatMessages([message])
loadChatList()
} catch (err) {
alert('Nachricht konnte nicht gesendet werden: ' + err.message)
} finally {
btn.disabled = false
input.focus()
}
}
async function openChatThread(customerId, customerName) {
adminChat.activeCustomerId = customerId
adminChat.activeCustomerName = customerName || 'Kunde'
adminChat.lastCreatedAt = ''
adminChat.shownIds = new Set()
if (adminChat.composer) adminChat.composer.destroy()
const thread = document.getElementById('admin-chat-thread')
thread.innerHTML = `
<div style="margin-bottom:8px"><strong>${escapeHtml(adminChat.activeCustomerName)}</strong></div>
<div class="chat-messages" id="admin-chat-messages"></div>
<div class="chat-input-row">
<textarea id="admin-chat-input" maxlength="2000" placeholder="Antwort an ${escapeHtml(adminChat.activeCustomerName)}…"></textarea>
<button type="button" class="mini-btn primary" id="admin-chat-send" style="padding:12px 18px">Senden</button>
</div>`
<div id="admin-chat-composer"></div>`
ChatComposer.trackScroll(document.getElementById('admin-chat-messages'))
document.getElementById('admin-chat-send').addEventListener('click', sendAdminChatMessage)
document.getElementById('admin-chat-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendAdminChatMessage()
}
// Text, Bilder, Kamerafoto, Dateien und Sprachnachrichten (public/chat-composer.js)
adminChat.composer = ChatComposer.create({
root: document.getElementById('admin-chat-composer'),
dropZone: thread,
uploadUrl: `${adminChatUrl(customerId)}/attachments`,
placeholder: `Antwort an ${adminChat.activeCustomerName}…`,
placeholderShort: 'Antwort…',
send: async ({ text, attachmentIds }) => {
const { message } = await api(`${adminChatUrl(customerId)}/messages`, {
method: 'POST',
body: JSON.stringify({ text, attachmentIds }),
})
return message
},
onSent: (message) => {
if (adminChat.activeCustomerId !== customerId) return
appendAdminChatMessages([message])
loadChatList()
},
})
try {
const { messages } = await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/messages`)
if (customerId !== adminChat.activeCustomerId) return // inzwischen andere Unterhaltung geoeffnet
appendAdminChatMessages(messages)
await api(`/api/portal-admin/chats/${encodeURIComponent(customerId)}/read`, { method: 'POST' }).catch(() => {})
updateAdminChatBadge(0)