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

+7
View File
@@ -82,6 +82,11 @@
background: rgba(255,255,255,0.06); color: inherit; font: inherit;
}
.chat-empty { display: flex; align-items: center; justify-content: center; flex: 1; opacity: 0.6; }
/* Anhaenge und Sprachnachrichten (chat-composer.css) im Tuerkis des Admin-Panels */
body {
--chat-accent: #2dd4bf; --chat-accent-hover: #5eead4; --chat-accent-soft: rgba(94,234,212,0.16);
--chat-accent-line: rgba(94,234,212,0.5); --chat-accent-text: #5eead4; --chat-on-accent: #042f2e;
}
/* Übernahmegutschriften */
.credit-filter { display: flex; gap: 8px; margin-bottom: 12px; }
@@ -126,6 +131,7 @@
.history-item { font-size: 13px; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
.history-item:last-child { border-bottom: none; }
</style>
<link rel="stylesheet" href="/chat-composer.css">
</head>
<body class="portal-page dashboard-page">
<div class="dot-background" aria-hidden="true">
@@ -259,6 +265,7 @@
</section>
</main>
<script src="/chat-composer.js"></script>
<script src="/admin.js"></script>
<script>
initAdminPage();
+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)
+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(() => {})
+238
View File
@@ -0,0 +1,238 @@
/* Chat: Anhaenge, Kamera und Sprachnachrichten (Kundenbereich + Admin-Panel, siehe chat-composer.js).
Farben ueber --chat-*: Standard ist das Lila des Kundenbereichs, admin.html setzt Tuerkis. */
:root {
--chat-accent: #9d3dfe;
--chat-accent-hover: #8a25f0;
--chat-accent-soft: rgba(157, 61, 254, 0.16);
--chat-accent-line: rgba(157, 61, 254, 0.5);
--chat-accent-text: #c9a2ff;
--chat-on-accent: #fff;
--chat-line: rgba(255, 255, 255, 0.16);
--chat-muted: rgba(255, 255, 255, 0.6);
--chat-surface: #17171b;
}
.chat-composer [hidden], .chat-overlay [hidden], .chat-bubble [hidden] { display: none !important; }
.chat-composer button, .chat-bubble button, .chat-bubble a, .chat-overlay button { -webkit-tap-highlight-color: transparent; }
.chat-ico { display: inline-grid; place-items: center; line-height: 0; }
.chat-ico svg { width: 20px; height: 20px; }
/* ------------------------------------------------------------- Verlauf --- */
.chat-bubble { white-space: normal; }
.chat-bubble .chat-text { white-space: pre-wrap; }
.chat-bubble.has-atts { padding: 6px 6px 8px; }
.chat-bubble.has-atts .chat-text, .chat-bubble.has-atts .chat-meta { padding: 0 8px; }
.chat-atts { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; }
.chat-atts + .chat-text { margin-top: 8px; }
.chat-att-images { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; width: 272px; max-width: 100%; }
.chat-att-images.single { display: block; width: auto; }
.chat-att-image {
position: relative; display: block; max-width: 100%; padding: 0; border: 0; border-radius: 12px; overflow: hidden;
background: rgba(255, 255, 255, 0.06); color: inherit; font: inherit; cursor: zoom-in;
}
.chat-att-image img { display: block; }
.chat-att-images.single img { max-width: 100%; max-height: 320px; height: auto; }
.chat-att-images:not(.single) .chat-att-image { aspect-ratio: 1; }
.chat-att-images:not(.single) img { width: 100%; height: 100%; object-fit: cover; }
.chat-att-image:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.chat-att-image.is-broken {
display: flex; align-items: center; gap: 8px; padding: 12px 14px; aspect-ratio: auto;
font-size: 13px; color: var(--chat-muted); cursor: default;
}
.chat-att-file {
display: flex; align-items: center; gap: 10px; width: 270px; max-width: 100%; box-sizing: border-box; padding: 8px 12px 8px 8px;
border-radius: 12px; background: rgba(0, 0, 0, 0.22); border: 1px solid rgba(255, 255, 255, 0.12);
color: inherit !important; text-decoration: none !important;
}
.chat-att-file:hover { background: rgba(0, 0, 0, 0.34); }
.chat-att-file > .chat-ico svg { width: 18px; height: 18px; opacity: 0.75; }
.chat-file-icon { position: relative; flex: none; width: 36px; height: 40px; display: grid; place-items: center; }
.chat-file-icon .chat-ico svg { width: 34px; height: 34px; stroke-width: 1.4; opacity: 0.9; }
.chat-file-ext {
position: absolute; left: 50%; bottom: 6px; transform: translateX(-50%); padding: 0 3px; border-radius: 3px;
font-size: 8.5px; line-height: 13px; font-weight: 800; letter-spacing: 0.02em; background: var(--chat-accent); color: var(--chat-on-accent);
white-space: nowrap; word-break: normal;
}
.chat-file-ext:empty { display: none; }
.chat-file-text { flex: 1; min-width: 0; display: grid; gap: 1px; }
.chat-file-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-file-size { font-size: 12px; opacity: 0.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-audio { display: flex; align-items: center; gap: 10px; width: 260px; max-width: 100%; box-sizing: border-box; padding: 2px; }
.chat-audio-play {
flex: none; width: 38px; height: 38px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center;
background: var(--chat-accent); color: var(--chat-on-accent); cursor: pointer;
}
.chat-bubble.mine .chat-audio-play { background: #fff; color: #17102a; }
.chat-audio-play .chat-ico svg { width: 16px; height: 16px; }
.chat-audio-main { flex: 1; min-width: 0; display: grid; gap: 7px; }
.chat-audio-label { font-size: 12px; font-weight: 600; opacity: 0.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-audio-bar { position: relative; display: block; height: 6px; border-radius: 999px; background: rgba(255, 255, 255, 0.2); cursor: pointer; }
.chat-audio-bar::after { content: ''; position: absolute; inset: -8px 0; }
.chat-audio-bar:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
.chat-audio-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: inherit; background: currentColor; }
.chat-audio-time { flex: none; min-width: 32px; text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; opacity: 0.8; }
.chat-audio-dl {
flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%;
color: inherit !important; opacity: 0.7;
}
.chat-audio-dl:hover { opacity: 1; background: rgba(255, 255, 255, 0.1); }
.chat-audio-dl .chat-ico svg { width: 16px; height: 16px; }
.chat-audio.is-error .chat-audio-time { opacity: 1; color: #fca5a5; font-size: 11px; }
/* ------------------------------------------------------- Eingabezeile --- */
.chat-composer { position: relative; margin-top: 12px; }
.chat-composer .chat-input-row { display: flex; align-items: flex-end; gap: 8px; margin-top: 0; }
.chat-composer textarea { flex: 1; min-width: 0; box-sizing: border-box; }
.chat-icon-btn {
flex: none; width: 46px; height: 46px; padding: 0; border-radius: 50%; display: grid; place-items: center;
border: 1px solid var(--chat-line); background: rgba(255, 255, 255, 0.05); color: inherit; cursor: pointer;
}
.chat-icon-btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.11); }
.chat-icon-btn:disabled { opacity: 0.45; cursor: default; }
.chat-icon-btn[aria-expanded="true"] { background: var(--chat-accent-soft); border-color: var(--chat-accent-line); }
.chat-icon-btn:focus-visible, .chat-send:focus-visible, .chat-audio-play:focus-visible, .chat-tray-remove:focus-visible {
outline: 2px solid var(--chat-accent-text); outline-offset: 2px;
}
.chat-attach { position: relative; flex: none; }
.chat-menu {
position: absolute; left: 0; bottom: calc(100% + 8px); z-index: 30; min-width: 190px; padding: 6px; display: grid; gap: 2px;
border-radius: 14px; background: var(--chat-surface); border: 1px solid var(--chat-line); box-shadow: 0 14px 36px rgba(0, 0, 0, 0.55);
}
.chat-menu button {
display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px 12px; border: 0; border-radius: 10px;
background: none; color: inherit; font: inherit; font-size: 14px; text-align: left; cursor: pointer;
}
.chat-menu button:hover, .chat-menu button:focus-visible { background: rgba(255, 255, 255, 0.08); outline: none; }
.chat-menu .chat-ico { color: var(--chat-accent-text); }
.chat-send {
flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 46px; padding: 0 20px;
border: none; border-radius: 999px; background: var(--chat-accent); color: var(--chat-on-accent);
font: inherit; font-weight: 700; cursor: pointer;
}
.chat-send:hover:not(:disabled) { background: var(--chat-accent-hover); }
.chat-send:disabled { opacity: 0.5; cursor: default; }
.chat-send.is-busy:disabled { opacity: 0.8; cursor: progress; }
.chat-send .chat-ico { display: none; }
.chat-send .chat-ico svg { width: 19px; height: 19px; }
.chat-composer-note { margin: 8px 2px 0; font-size: 13px; line-height: 1.45; color: #fca5a5; }
.chat-composer-note.is-info { color: var(--chat-muted); }
/* Vorschau der Anhaenge vor dem Senden */
.chat-tray { display: flex; gap: 8px; padding: 2px 2px 10px; overflow-x: auto; scrollbar-width: thin; }
.chat-tray-item {
position: relative; flex: none; display: flex; align-items: center; gap: 10px; height: 64px; box-sizing: border-box;
border-radius: 12px; border: 1px solid var(--chat-line); background: rgba(255, 255, 255, 0.05); overflow: hidden;
}
.chat-tray-item.is-image { width: 64px; }
.chat-tray-item.is-image img { display: block; width: 100%; height: 100%; object-fit: cover; }
.chat-tray-item.is-image.is-queued img, .chat-tray-item.is-image.is-uploading img { opacity: 0.55; }
.chat-tray-item.is-file { width: 220px; padding: 0 36px 0 8px; }
.chat-tray-item.is-file.is-error { padding-right: 64px; }
.chat-tray-item.is-voice { width: 280px; padding: 0 36px 0 10px; }
.chat-tray-item.is-voice .chat-audio { width: 100%; padding: 0; }
.chat-tray-progress { position: absolute; left: 0; bottom: 0; height: 3px; width: 0; background: var(--chat-accent); transition: width 0.2s; }
.chat-tray-item.is-done .chat-tray-progress { opacity: 0; transition: opacity 0.6s 0.3s; }
.chat-tray-item.is-error { border-color: rgba(248, 113, 113, 0.75); background: rgba(248, 113, 113, 0.08); }
.chat-tray-item.is-error .chat-file-size { color: #fca5a5; opacity: 1; }
.chat-tray-remove, .chat-tray-retry {
position: absolute; top: 5px; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; display: grid; place-items: center;
background: rgba(0, 0, 0, 0.72); color: #fff; cursor: pointer;
}
.chat-tray-remove { right: 5px; }
.chat-tray-retry { right: 34px; }
.chat-tray-item.is-image .chat-tray-retry { top: 50%; left: 50%; right: auto; width: 30px; height: 30px; transform: translate(-50%, -50%); }
.chat-tray-remove .chat-ico svg, .chat-tray-retry .chat-ico svg { width: 14px; height: 14px; }
/* Sprachaufnahme (ersetzt waehrenddessen Textfeld und Anhang-Knopf) */
.chat-rec {
display: none; flex: 1; min-width: 0; align-items: center; gap: 10px; height: 46px; box-sizing: border-box; padding: 0 5px;
border-radius: 999px; border: 1px solid rgba(248, 113, 113, 0.45); background: rgba(248, 113, 113, 0.08);
}
.chat-composer.is-recording .chat-rec { display: flex; }
.chat-composer.is-recording .chat-attach,
.chat-composer.is-recording textarea,
.chat-composer.is-recording .chat-mic { display: none; }
.chat-rec .chat-icon-btn { width: 36px; height: 36px; border: 0; background: transparent; }
.chat-rec .chat-icon-btn:hover { background: rgba(255, 255, 255, 0.1); }
.chat-rec-stop { color: #f87171; }
.chat-rec-dot { flex: none; width: 10px; height: 10px; border-radius: 50%; background: #f87171; animation: chat-rec-pulse 1.2s ease-in-out infinite; }
.chat-rec-label { flex: none; font-size: 13px; color: var(--chat-muted); }
.chat-rec-time { flex: none; min-width: 36px; font-weight: 700; font-variant-numeric: tabular-nums; }
.chat-rec-wave { flex: 1; min-width: 0; height: 24px; display: flex; align-items: center; justify-content: flex-end; gap: 2px; overflow: hidden; color: #fca5a5; }
.chat-rec-wave i { flex: none; width: 3px; height: 100%; border-radius: 2px; background: currentColor; transform: scaleY(0.1); transition: transform 0.09s linear; }
@keyframes chat-rec-pulse { 50% { opacity: 0.25; } }
/* Dateien in den Chat ziehen */
.chat-dropzone { position: relative; }
.chat-drop-active::after {
content: 'Dateien hier ablegen'; position: absolute; inset: 0; z-index: 40; display: grid; place-items: center;
border: 2px dashed var(--chat-accent-line); border-radius: inherit; background: rgba(10, 10, 12, 0.85);
color: #fff; font-weight: 700; pointer-events: none;
}
/* ---------------------------------------------- Bildansicht und Kamera --- */
html.chat-overlay-open, html.chat-overlay-open body { overflow: hidden; }
.chat-overlay {
position: fixed; inset: 0; z-index: 1000; display: flex; flex-direction: column;
background: rgba(4, 4, 6, 0.95); color: #fff; font-family: inherit;
-webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
.chat-ov-top { flex: none; display: flex; align-items: center; gap: 10px; padding: 12px 14px; padding-top: max(12px, env(safe-area-inset-top)); }
.chat-ov-title { flex: 1; min-width: 0; font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-ov-btn {
flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 44px; height: 44px; padding: 0 12px;
box-sizing: border-box; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.25); background: rgba(255, 255, 255, 0.1);
color: #fff !important; font: inherit; font-size: 14px; font-weight: 600; text-decoration: none; cursor: pointer;
}
.chat-ov-btn:hover { background: rgba(255, 255, 255, 0.2); }
.chat-ov-btn:focus-visible, .chat-shutter:focus-visible { outline: 2px solid #fff; outline-offset: 3px; }
.chat-ov-btn.wide { padding: 0 18px; }
.chat-ov-btn.primary { background: var(--chat-accent); border-color: transparent; color: var(--chat-on-accent) !important; }
.chat-ov-btn.primary:hover { background: var(--chat-accent-hover); }
.chat-lightbox-stage { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 0 12px 16px; }
.chat-lightbox-stage img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); }
.chat-cam-stage { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 0 12px; }
.chat-cam-video, .chat-cam-still { max-width: 100%; max-height: 100%; border-radius: 14px; background: #000; }
.chat-cam-video.is-mirrored { transform: scaleX(-1); }
.chat-camera:not(.is-review) .chat-cam-still, .chat-camera.is-review .chat-cam-video { display: none; }
.chat-cam-msgbox { max-width: 420px; display: grid; gap: 16px; justify-items: center; text-align: center; }
.chat-cam-msg { margin: 0; line-height: 1.5; color: rgba(255, 255, 255, 0.88); }
.chat-cam-controls {
flex: none; display: flex; align-items: center; justify-content: center; gap: 24px;
padding: 18px 16px; padding-bottom: max(18px, env(safe-area-inset-bottom));
}
.chat-camera .chat-cam-review, .chat-camera.is-review .chat-cam-live { display: none; }
.chat-camera.is-review .chat-cam-review { display: flex; }
.chat-cam-side { flex: 0 1 110px; display: flex; justify-content: center; }
.chat-shutter {
flex: none; width: 74px; height: 74px; padding: 0; border-radius: 50%; border: 4px solid #fff;
background: rgba(255, 255, 255, 0.18); box-shadow: inset 0 0 0 4px rgba(0, 0, 0, 0.35); cursor: pointer;
}
.chat-shutter:hover:not(:disabled) { background: rgba(255, 255, 255, 0.35); }
.chat-shutter:active:not(:disabled) { background: #fff; }
.chat-shutter:disabled { opacity: 0.4; cursor: default; }
@media (max-width: 640px) {
.chat-composer .chat-input-row { gap: 6px; }
.chat-composer textarea { font-size: 16px; } /* iOS zoomt sonst beim Tippen hinein */
.chat-icon-btn { width: 42px; height: 42px; }
.chat-send { width: 42px; height: 42px; padding: 0; }
.chat-send .chat-ico { display: inline-grid; }
.chat-send .chat-send-label { display: none; }
.chat-rec { height: 42px; }
.chat-rec-label { display: none; }
.chat-tray-item.is-file { width: 190px; }
.chat-tray-item.is-voice { width: 250px; }
}
@media (prefers-reduced-motion: reduce) {
.chat-rec-dot { animation: none; }
.chat-rec-wave i, .chat-tray-progress { transition: none; }
}
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -6,6 +6,7 @@
<title>Kundenbereich – WEBklar</title>
<link rel="stylesheet" href="/login.css">
<link rel="stylesheet" href="/kundenbereich.css">
<link rel="stylesheet" href="/chat-composer.css">
</head>
<body class="portal-page dashboard-page kb-page">
<div class="dot-background" aria-hidden="true">
@@ -90,11 +91,9 @@
</header>
<div class="kb-panel chat-box">
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-input-row">
<textarea id="chat-input" maxlength="2000" placeholder="Nachricht an das WEBklar-Team…"></textarea>
<button type="button" class="chat-send" id="chat-send">Senden</button>
</div>
<p class="chat-hint">Enter = Senden, Shift+Enter = Zeilenumbruch.</p>
<!-- Eingabezeile mit Bild, Kamera, Datei und Sprachnachricht (chat-composer.js) -->
<div id="chat-composer"></div>
<p class="chat-hint">Enter = Senden, Shift+Enter = neue Zeile. Bilder und Dateien bis 25 MB – auch per Einfügen oder Hineinziehen.</p>
</div>
</section>
@@ -120,6 +119,7 @@
</main>
</div>
<script src="/chat-composer.js"></script>
<script src="/app.js"></script>
<script>
initDashboardPage();
+2 -1
View File
@@ -44,7 +44,7 @@
<div class="brand">WEBKLAR</div>
<h1>Datenschutzerklärung</h1>
<p class="meta">Stand 25.09.2026</p>
<p class="meta">Stand 26.09.2026</p>
<p>Hier erfahren Sie, welche personenbezogenen Daten wir verarbeiten, wenn Sie unsere Websites besuchen, unser Kundenportal nutzen oder mit uns einen Vertrag schließen, und welche Rechte Sie haben.</p>
@@ -78,6 +78,7 @@
<h2>6. Kundenportal</h2>
<p>Für Ihr Kundenkonto verarbeiten wir Name, E-Mail-Adresse, Passwort (nur verschlüsselt gespeichert), Kundennummer sowie gegebenenfalls Anschrift und Telefonnummer. Im Portal kommen hinzu: Ihre Projekte (Dateien und Änderungsverlauf Ihrer Website), unsere Arbeitsnachweise, Nachrichten im Chat, Rechnungen und Ihr Stundenkontingent. Schreiben Sie uns im Chat, erhalten unsere Mitarbeiter eine E-Mail-Benachrichtigung mit Ihrer Nachricht. Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so ansehen, wie Sie es sehen. Eine Nachricht in Ihrem Namen senden können sie dabei nicht.</p>
<p id="chat-anhaenge">Im Chat können Sie uns auch Bilder, Fotos, Dateien und Sprachnachrichten schicken. Auf Kamera und Mikrofon greift das Portal nur zu, während Sie selbst ein Foto aufnehmen oder eine Sprachnachricht aufzeichnen, und nur, wenn Sie das in Ihrem Browser erlauben. Anhänge werden schon beim Hinzufügen auf unseren Server übertragen. Nicht gesendete Anhänge, auch solche, die Sie vor dem Senden wieder entfernen, löschen wir spätestens nach 24 Stunden. Gesendete Anhänge speichern wir wie Ihre Chat-Nachrichten. Die E-Mail-Benachrichtigung an unsere Mitarbeiter nennt nur Art und Namen der Anhänge, nicht die Dateien selbst.</p>
<p>Alle Portal-Daten liegen in unseren selbst betriebenen Systemen auf unserem Server bei Hetzner. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Wir speichern die Daten für die Dauer der Geschäftsbeziehung und danach bis zum Ablauf der Verjährungsfrist (in der Regel drei Jahre). Rechnungen bewahren wir wie in Abschnitt 9 beschrieben auf.</p>
<h2>7. Wartungs-Abo und Zahlung über Stripe</h2>
-2
View File
@@ -279,8 +279,6 @@
flex: 1; resize: none; min-height: 46px; max-height: 130px; padding: 12px 14px; border-radius: 14px;
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit;
}
.chat-send { padding: 12px 20px; border: none; border-radius: 999px; background: var(--kb-purple); color: #fff; font: inherit; font-weight: 700; cursor: pointer; }
.chat-send:disabled { opacity: 0.5; cursor: default; }
.chat-hint { margin-top: 6px; font-size: 12px; color: var(--kb-faint); }
/* ------------------------------------------------ Wartungs-Abo (Inhalt) --- */