Files
aza/AzA march 2026/empfang_chat_minimal.html
2026-05-08 14:53:53 +02:00

798 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AzA-Empfang Chat</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:#eaf1f8;color:#1a2a3a;font-size:10pt}
:root{--aza-blue:#5B8DB3;--aza-deep:#356488;--aza-rail:linear-gradient(180deg,#3d6f92 0%,#2a5472 100%);--aza-sh:0 4px 18px rgba(28,62,94,.08)}
body{display:flex;flex-direction:column;min-height:100%;overflow:hidden}
header{background:linear-gradient(135deg,#5B8DB3,#3a6d93);color:#fff;padding:8px 14px;flex-shrink:0;z-index:30;box-shadow:0 2px 12px rgba(40,80,110,.2)}
.hdr-row1{display:flex;flex-wrap:wrap;align-items:flex-start;gap:10px}
.hdr-main-titles{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
header h1{font-size:.95rem;font-weight:700;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0}
.hdr-sub{font-size:.62rem;font-weight:500;opacity:.88;line-height:1.32;white-space:normal;word-wrap:break-word}
.hdr-tools{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-left:auto}
.hdr-tools .cw-ton-lbl{font-size:.62rem;opacity:.92;text-transform:uppercase;letter-spacing:.04em}
.hdr-tools input[type=range]{width:80px;accent-color:#fff;cursor:pointer}
.hdr-tools span.vol-lbl{font-size:.68rem;min-width:36px;text-align:right;font-weight:600}
.hdr-tools button{background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.38);color:#fff;border-radius:8px;padding:5px 10px;font-size:.75rem;cursor:pointer;font-family:inherit}
.hdr-tools button:hover{background:rgba(255,255,255,.24)}
.hdr-tools button.on{background:rgba(255,255,255,.32)}
.hdr-tools button.muted{opacity:.6}
#cw-settings{background:#fff;border-bottom:1px solid #dde8f0;padding:12px 16px;font-size:.82rem;flex-shrink:0;box-shadow:var(--aza-sh)}
#cw-settings.hidden{display:none!important}
#cw-settings label{display:block;margin-bottom:4px;color:#3a5a7a;font-weight:600}
#cw-settings select{width:100%;max-width:340px;padding:8px;border:1px solid #d0dce8;border-radius:10px}
#cw-settings .set-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
.shell-body{display:flex;flex:1;min-height:0;overflow:hidden}
#nav-mini{width:72px;min-width:72px;flex-shrink:0;background:var(--aza-rail);display:flex;flex-direction:column;align-items:center;padding:14px 0;border-right:1px solid rgba(255,255,255,.12)}
.nr-m-logo{width:44px;height:44px;border-radius:12px;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.28);color:#fff;font:700 .65rem/1.1 'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;text-align:center;padding:4px;margin-bottom:12px}
.nr-m-btn{width:44px;height:44px;border:none;border-radius:11px;background:transparent;color:#fff;font-size:1.2rem;cursor:pointer;margin-bottom:10px;display:flex;align-items:center;justify-content:center}
.nr-m-btn:hover{background:rgba(255,255,255,.12)}
#main-col-wrap{flex:1;display:flex;flex-direction:column;min-width:0;background:#f0f6fb}
.chat-strip{padding:10px 20px;background:#fafcfe;border-bottom:1px solid #e2ebf5;font-size:.72rem;font-weight:700;color:#62809a;text-transform:uppercase;letter-spacing:.07em}
#main-col{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden}
#pending-strip{display:none;flex-shrink:0;padding:10px 16px;background:#e8f2fa;border-bottom:1px solid #d2e4f4;gap:10px;flex-wrap:wrap}
#pending-strip.has-items{display:flex}
.pend-item{position:relative;border:1px solid #b8cce0;border-radius:12px;overflow:hidden;background:#fff;box-shadow:var(--aza-sh)}
.pend-item img{display:block;width:76px;height:76px;object-fit:cover}
.pend-item .rm{position:absolute;top:4px;right:4px;width:22px;height:22px;border:none;border-radius:50%;background:#c0392b;color:#fff;font-size:12px;cursor:pointer}
#log{flex:1;overflow-y:auto;padding:20px clamp(16px,4vw,40px);display:flex;flex-direction:column;gap:4px}
.msg-date-sep{text-align:center;margin:14px 0 8px}
.msg-date-sep span{display:inline-block;padding:4px 12px;border-radius:999px;background:rgba(255,255,255,.95);border:1px solid #d8e6f4;font-size:.65rem;font-weight:700;color:#5a7590}
#log::-webkit-scrollbar{width:10px}
#log::-webkit-scrollbar-thumb{background:#bfd5e8;border-radius:999px;border:2px solid #f0f6fb}
.msg{max-width:min(92%,640px);padding:13px 16px;border-radius:16px;font-size:.89rem;line-height:1.55;word-wrap:break-word;border:1px solid transparent;box-shadow:0 1px 3px rgba(28,62,94,.06)}
.msg.me{align-self:flex-end;background:linear-gradient(165deg,#d9ebf8,#cae3f5);border-color:#aecfe4;border-bottom-right-radius:5px}
.msg.them{align-self:flex-start;background:#fff;border-color:#e4ebf4;border-bottom-left-radius:5px;box-shadow:0 4px 14px rgba(28,62,94,.06)}
.msg .meta{font-size:.66rem;color:#7693ab;margin-bottom:6px;font-weight:600}
.msg img{max-width:100%;max-height:260px;border-radius:12px;margin-top:8px;border:1px solid #e8eef4}
.drop-hint{font-size:.72rem;color:#6a8499;text-align:center;padding:10px;background:#eef4fa;border-top:1px dashed #cddfea;flex-shrink:0}
.drop-hint.drag{background:#e0eef8}
#bar{flex-shrink:0;background:#fafcfe;border-top:1px solid #dfe8ef;padding:14px clamp(14px,3vw,28px) 18px;box-shadow:0 -6px 24px rgba(40,76,103,.07)}
#bar-inner{max-width:720px;margin:0 auto;display:flex;gap:12px;align-items:flex-end}
#bar .grow-inp{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}
#bar textarea{width:100%;border:1px solid #d0dde9;border-radius:16px;padding:14px 16px;font-family:inherit;font-size:.92rem;resize:none;min-height:52px;max-height:148px;background:#fff;outline:none}
#bar textarea:focus{border-color:#8eb8d6;box-shadow:0 0 0 3px rgba(91,141,179,.13)}
#bar .row{display:flex;gap:10px;flex-shrink:0;align-items:center}
#bar button{background:linear-gradient(180deg,var(--aza-blue),#4a86ad);color:#fff;border:none;border-radius:13px;padding:14px 22px;font-weight:700;font-size:.82rem;font-family:inherit;cursor:pointer;box-shadow:0 4px 12px rgba(61,118,157,.32)}
#bar button:hover{transform:translateY(-1px)}
#bar button:disabled{opacity:.52;cursor:not-allowed;transform:none}
#bar button.btn-dictate{background:#eef5fb;color:#356488;border:1px solid #c8dae8;box-shadow:none}
#bar button.btn-dictate:hover{background:#e4eef6}
#bar button.btn-dictate.recording{background:#fde8e8;border-color:#e8bcbc;color:#943535}
#att-preview{font-size:.72rem;color:var(--aza-deep);font-weight:600;min-height:1.15em}
#gate{position:fixed;inset:0;background:#eaf1f8;display:flex;align-items:center;justify-content:center;padding:20px;z-index:100}
#gate .box{background:#fff;padding:28px;border-radius:16px;max-width:380px;border:1px solid #e6eef6;text-align:center;box-shadow:var(--aza-sh)}
#gate a{color:#5B8DB3;font-weight:700}
.hidden{display:none!important}
@media (max-width:640px){#nav-mini{width:56px;min-width:56px}.nr-m-btn,.nr-m-logo{width:40px;height:40px;font-size:1rem}}
</style>
</head>
<body>
<div id="gate" class="hidden">
<div class="box">
<p id="gate-msg">Bitte zuerst im Empfang anmelden.</p>
<p style="margin-top:12px"><a id="gate-link" href="#">Empfang öffnen</a></p>
</div>
</div>
<header>
<div class="hdr-row1">
<div class="hdr-main-titles">
<h1 id="hdr-title">Chat</h1>
<div class="hdr-sub" id="hdr-practice-sub" aria-live="polite"></div>
</div>
<div class="hdr-tools" title="Ton (Benachrichtigungen)">
<span class="cw-ton-lbl" aria-hidden="true">Ton</span>
<input type="range" id="cw-vol" min="0" max="300" step="5" title="Lautstärke" aria-label="Lautstärke Benachrichtigung">
<span class="vol-lbl" id="cw-vol-disp">100%</span>
<button type="button" id="cw-sound" onclick="toggleSound()" title="Ton an/aus">&#128276;</button>
<button type="button" id="cw-gear" onclick="toggleSettings()" title="Klangsignal wählen">&#9881;</button>
<button type="button" onclick="reloadThread()" title="Aktualisieren">&#8635;</button>
</div>
</div>
</header>
<div id="cw-settings" class="hidden">
<label for="cw-tone">Signal bei neuer Nachricht</label>
<select id="cw-tone" onchange="saveToneChoice()"></select>
<div class="set-row">
<button type="button" class="btn-test" onclick="testSound()" style="background:#e8f0f8;color:#2a5a8a;border:1px solid #c8d8e8;border-radius:6px;padding:6px 12px;cursor:pointer;font-family:inherit;font-size:.8rem">Klang testen</button>
</div>
<p style="margin-top:10px;font-size:.75rem;color:#8a9aaa">Einstellungen werden wie im Haupt-Empfang gespeichert (dieser Browser).</p>
</div>
<div class="shell-body">
<nav id="nav-mini" aria-label="Kurzbefehle">
<div class="nr-m-logo" title="AzA-Empfang">AzA</div>
<button type="button" class="nr-m-btn" onclick="reloadThread()" title="Aktualisieren">&#8635;</button>
<button type="button" class="nr-m-btn" onclick="toggleSettings()" title="Einstellungen">&#9881;</button>
</nav>
<div id="main-col-wrap">
<div class="chat-strip" id="chat-context-strip">Nachrichten</div>
<div id="main-col">
<div id="pending-strip"></div>
<div id="log"></div>
</div>
<div class="drop-hint" id="drop-hint">Bilder hierher ziehen oder in das Textfeld einfügen (Strg+V)</div>
<div id="bar">
<div id="bar-inner">
<div class="grow-inp">
<textarea id="tx" placeholder="Nachricht… (Enter = senden, Umschalt+Enter = Zeile)" rows="2"></textarea>
<div id="att-preview"></div>
</div>
<div class="row">
<button type="button" id="btn-dictate" class="btn-dictate" onclick="toggleDictate()">Diktieren</button>
<button type="button" id="btn-send" onclick="doSend()">Senden</button>
</div>
</div>
</div>
</div>
</div>
<script>
var API_BASE = window.location.origin + '/empfang';
var MAX_B64 = 2 * 1024 * 1024;
var mode = 'general';
var peerName = '';
var peerUserId = '';
var threadId = null;
var currentSession = null;
var pollTimer = null;
var pendingAttach = [];
var lastPollSig = '';
var lastServerTick = -1;
var lastConvMessages = [];
var dictateRec = null;
var dictateListening = false;
function stopDictateIfActive() {
if (!dictateListening) return;
dictateListening = false;
try {
if (dictateRec) dictateRec.stop();
} catch (e) {}
dictateRec = null;
var b = document.getElementById('btn-dictate');
if (b) {
b.textContent = 'Diktieren';
b.classList.remove('recording');
}
}
function toggleDictate() {
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
var btn = document.getElementById('btn-dictate');
if (!SR) {
alert('Spracherkennung wird in diesem Browser nicht unterstützt.');
return;
}
if (dictateListening) {
stopDictateIfActive();
return;
}
dictateRec = new SR();
dictateRec.lang = 'de-DE';
dictateRec.continuous = true;
dictateRec.interimResults = true;
dictateListening = true;
btn.textContent = 'Stoppen';
btn.classList.add('recording');
var tx = document.getElementById('tx');
try {
tx.focus();
} catch (e0) {}
dictateRec.onresult = function(ev) {
var finalChunk = '';
for (var i = ev.resultIndex; i < ev.results.length; i++) {
if (ev.results[i].isFinal) finalChunk += ev.results[i][0].transcript;
}
if (!finalChunk) return;
var t = (tx.value || '');
var add = finalChunk.trim();
if (!add) return;
if (t && !/\s$/.test(t)) tx.value = t + ' ';
tx.value += add + ' ';
try {
tx.scrollTop = tx.scrollHeight;
} catch (e1) {}
};
dictateRec.onerror = function(ev) {
var code = ev && ev.error ? String(ev.error) : '';
if (code === 'no-speech' || code === 'aborted') return;
stopDictateIfActive();
if (code === 'not-allowed') alert('Mikrofonzugriff wurde verweigert.');
};
dictateRec.onend = function() {
if (dictateListening) {
try {
dictateRec.start();
} catch (e2) {}
}
};
try {
dictateRec.start();
} catch (e3) {
dictateListening = false;
btn.textContent = 'Diktieren';
btn.classList.remove('recording');
alert('Diktieren konnte nicht gestartet werden.');
}
}
var soundEnabled = localStorage.getItem('empfang_sound') !== 'off';
var audioCtx = null;
var volume = parseFloat(localStorage.getItem('empfang_volume') || '1');
if (isNaN(volume)) volume = 1;
var currentToneIdx = parseInt(localStorage.getItem('empfang_tone_idx') || '0', 10);
var TONE_PRESETS = [
{name:'Sanftes Glockenspiel', notes:[{f:523,d:.15},{f:659,d:.15},{f:784,d:.3}], wave:'sine', vol:.12},
{name:'Zwei-Ton Harmonisch', notes:[{f:392,d:.2},{f:523,d:.3}], wave:'sine', vol:.12},
{name:'Drei-Ton Melodie', notes:[{f:523,d:.12},{f:587,d:.12},{f:659,d:.28}], wave:'sine', vol:.11},
{name:'Kristallklar', notes:[{f:1319,d:.5}], wave:'sine', vol:.07},
{name:'Warmer Akkord', notes:[{f:262,d:.4}], wave:'triangle', vol:.14},
{name:'Aufstieg', notes:[{f:262,d:.09},{f:330,d:.09},{f:392,d:.09},{f:523,d:.22}], wave:'sine', vol:.10},
{name:'Sanfte Welle', notes:[{f:440,d:.55}], wave:'sine', vol:.10},
{name:'Tropfen', notes:[{f:659,d:.12},{f:587,d:.12},{f:523,d:.28}], wave:'sine', vol:.10},
{name:'Morgengruss', notes:[{f:523,d:.14},{f:392,d:.14},{f:523,d:.28}], wave:'sine', vol:.12},
{name:'Zephyr', notes:[{f:880,d:.45}], wave:'sine', vol:.06},
{name:'Bambus', notes:[{f:330,d:.18},{f:440,d:.28}], wave:'triangle', vol:.12},
{name:'Silberglocke', notes:[{f:988,d:.45}], wave:'sine', vol:.08},
{name:'Meditation', notes:[{f:262,d:.65}], wave:'sine', vol:.12},
{name:'Horizont', notes:[{f:587,d:.18},{f:880,d:.32}], wave:'sine', vol:.10},
{name:'Stille Post', notes:[{f:784,d:.4}], wave:'sine', vol:.08},
];
function parseQs() {
var p = new URLSearchParams(window.location.search);
mode = (p.get('mode') || 'general').toLowerCase() === 'dm' ? 'dm' : 'general';
try {
peerName = decodeURIComponent((p.get('peer') || '').trim());
} catch (e) {
peerName = (p.get('peer') || '').trim();
}
peerUserId = (p.get('peer_uid') || '').trim();
if (mode === 'dm' && !peerName) mode = 'general';
}
async function checkAuth() {
try {
var r = await fetch(API_BASE + '/auth/me', { credentials: 'include' });
if (r.status === 401) return null;
var d = await r.json();
if (d.authenticated) return d;
} catch (e) {}
return null;
}
function showGate(msg) {
document.getElementById('gate').classList.remove('hidden');
document.getElementById('gate-msg').textContent = msg;
var base = window.location.origin + '/empfang/';
document.getElementById('gate-link').href = base;
document.getElementById('gate-link').onclick = function(e) {
e.preventDefault();
window.open(base, '_blank');
};
}
function setTitle() {
var t = document.getElementById('hdr-title');
if (mode === 'dm' && peerName) t.textContent = 'Chat mit ' + peerName;
else t.textContent = 'Neuer Chat Allgemein';
var cs = document.getElementById('chat-context-strip');
if (cs) cs.textContent = mode === 'dm' && peerName ? 'Direkt · ' + peerName : 'Praxis-Posteingang · Allgemein';
}
async function loadPracticeSubline() {
try {
var r = await fetch(API_BASE + '/practice/info', { credentials: 'include' });
if (!r.ok) return;
var d = await r.json();
var el = document.getElementById('hdr-practice-sub');
if (!el) return;
var pname = String(d.practice_name || '').trim() || 'Praxis-Chat';
var pid = String(d.practice_id || '').trim();
var sid = pid ? (pid.length > 14 ? pid.slice(0, 12) + '\u2026' : pid) : '';
if (!pid) {
el.textContent = pname + ' \u2013 Praxiszuordnung pr\u00fcfen';
return;
}
el.textContent = pname + ' \u00b7 ID ' + sid + ' \u00b7 mit Praxis-Chat verbunden';
} catch (e) {}
}
function mimeForAttachment(name, fallbackMime) {
var fm = (fallbackMime || '').toLowerCase();
if (fm.indexOf('image/') === 0) return fm;
var n = (name || '').toLowerCase();
if (n.endsWith('.png')) return 'image/png';
if (n.endsWith('.gif')) return 'image/gif';
if (n.endsWith('.webp')) return 'image/webp';
if (n.endsWith('.bmp')) return 'image/bmp';
if (n.endsWith('.jpg') || n.endsWith('.jpeg')) return 'image/jpeg';
return 'image/png';
}
function fileToAttach(file) {
return new Promise(function(resolve, reject) {
if (file.size > MAX_B64) {
reject(new Error('Datei zu groß (max. ca. 2 MB)'));
return;
}
var fr = new FileReader();
fr.onload = function() {
var s = fr.result;
var i = s.indexOf(',');
resolve({
name: file.name || 'bild.png',
data: i >= 0 ? s.slice(i + 1) : s,
mime: file.type || mimeForAttachment(file.name, '')
});
};
fr.onerror = function() { reject(new Error('Lesefehler')); };
fr.readAsDataURL(file);
});
}
function renderPendingStrip() {
var strip = document.getElementById('pending-strip');
if (!strip) return;
strip.innerHTML = '';
if (!pendingAttach.length) {
strip.classList.remove('has-items');
return;
}
strip.classList.add('has-items');
pendingAttach.forEach(function(a, idx) {
var wrap = document.createElement('div');
wrap.className = 'pend-item';
var img = document.createElement('img');
img.src = 'data:' + (a.mime || mimeForAttachment(a.name)) + ';base64,' + a.data;
img.alt = a.name || '';
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'rm';
rm.innerHTML = '\u00d7';
rm.title = 'Entfernen';
rm.onclick = function() {
pendingAttach.splice(idx, 1);
renderPendingStrip();
document.getElementById('att-preview').textContent =
pendingAttach.length ? (pendingAttach.length + ' Bild(er) werden mitgesendet') : '';
};
wrap.appendChild(img);
wrap.appendChild(rm);
strip.appendChild(wrap);
});
}
function addFiles(files) {
var arr = Array.from(files || []);
var work = arr.filter(function(f) { return f.type && f.type.indexOf('image/') === 0; });
if (!work.length) return;
Promise.all(work.map(function(f) {
return fileToAttach(f).then(function(a) { pendingAttach.push(a); });
})).then(function() {
renderPendingStrip();
document.getElementById('att-preview').textContent =
pendingAttach.length ? (pendingAttach.length + ' Bild(er) werden mitgesendet') : '';
}).catch(function(e) {
alert(e.message || String(e));
});
}
function setupDropPaste() {
var hint = document.getElementById('drop-hint');
var tx = document.getElementById('tx');
if (!hint || !tx) return;
['dragenter','dragover'].forEach(function(ev) {
hint.addEventListener(ev, function(e) {
e.preventDefault();
e.stopPropagation();
hint.classList.add('drag');
});
});
hint.addEventListener('dragleave', function(e) {
hint.classList.remove('drag');
});
hint.addEventListener('drop', function(e) {
e.preventDefault();
hint.classList.remove('drag');
addFiles(e.dataTransfer && e.dataTransfer.files);
});
document.body.addEventListener('dragover', function(e) { e.preventDefault(); });
document.body.addEventListener('drop', function(e) {
if (e.target === hint || hint.contains(e.target)) return;
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
e.preventDefault();
addFiles(e.dataTransfer.files);
}
});
tx.addEventListener('paste', function(e) {
var items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (var i = 0; i < items.length; i++) {
if (items[i].type && items[i].type.indexOf('image/') === 0) {
e.preventDefault();
var f = items[i].getAsFile();
if (f) addFiles([f]);
}
}
});
tx.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
doSend();
}
});
}
function patientLine() {
var now = new Date();
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
return 'Chat · ' + pad(now.getDate()) + '.' + pad(now.getMonth() + 1) + '. ' +
pad(now.getHours()) + ':' + pad(now.getMinutes());
}
function conversationAudienceParam() {
if (mode === 'dm' && peerName) return peerName;
return '';
}
function conversationSortChrono(msgs) {
return msgs.slice().sort(function(a, b) {
return (a.empfangen || a.zeitstempel || '').localeCompare(b.empfangen || b.zeitstempel || '');
});
}
function conversationDayKey(iso) {
var raw = String(iso || '').replace(' ', 'T');
var d = Date.parse(raw);
if (!isFinite(d)) return '';
var dt = new Date(d);
var y = dt.getFullYear(), mo = dt.getMonth() + 1, da = dt.getDate();
return y + '-' + (mo < 10 ? '0' : '') + mo + '-' + (da < 10 ? '0' : '') + da;
}
function conversationDateLabel(iso) {
var raw = String(iso || '').replace(' ', 'T');
var d = Date.parse(raw);
if (!isFinite(d)) return '';
var msgDate = new Date(d);
var today = new Date();
today.setHours(0, 0, 0, 0);
var dd = new Date(msgDate);
dd.setHours(0, 0, 0, 0);
var diffDays = Math.round((today.getTime() - dd.getTime()) / 86400000);
if (diffDays === 0) return 'Heute';
if (diffDays === 1) return 'Gestern';
return msgDate.getDate() + '.' + (msgDate.getMonth() + 1) + '.' + msgDate.getFullYear();
}
function conversationAnchorLastId() {
var s = conversationSortChrono(lastConvMessages);
if (!s.length) return null;
return s[s.length - 1].id || null;
}
function messageBodyLines(m) {
var parts = [];
if (m.therapieplan && String(m.therapieplan).trim()) parts.push('Therapieplan:\n' + m.therapieplan);
if (m.procedere && String(m.procedere).trim()) parts.push('Procedere:\n' + m.procedere);
if (m.medikamente && String(m.medikamente).trim()) parts.push('Medikamente:\n' + m.medikamente);
var k = (m.kommentar || '').trim();
if (k && k !== '\u200b') parts.push(k);
return parts.join('\n\n');
}
async function doSend() {
stopDictateIfActive();
var tx = document.getElementById('tx');
var text = (tx.value || '').trim();
if (!text && !pendingAttach.length) return;
if (!currentSession) return;
var extras = {};
if (mode === 'dm' && peerName) {
extras.recipient = peerName;
if (peerUserId) extras.recipient_user_id = peerUserId;
}
var selfUid = String((currentSession.user_id || '')).trim();
if (selfUid) extras.sender_user_id = selfUid;
var anchor = conversationAnchorLastId();
if (anchor) extras.reply_to = anchor;
if (pendingAttach.length) {
extras.attachments = pendingAttach.map(function(a) {
return { name: a.name, data: a.data };
});
}
var payload = {
medikamente: '', therapieplan: '', procedere: '',
kommentar: text || (pendingAttach.length ? '\u200b' : ''),
patient: mode === 'dm' && peerName ? ('Direkt: ' + peerName) : patientLine(),
absender: currentSession.display_name + ' (Empfang)',
zeitstempel: new Date().toISOString().slice(0, 19).replace('T', ' '),
extras: extras
};
document.getElementById('btn-send').disabled = true;
try {
var r = await fetch(API_BASE + '/send', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
var d = await r.json().catch(function() { return {}; });
if (r.ok && d.success) {
threadId = d.thread_id || d.id || threadId;
tx.value = '';
pendingAttach = [];
renderPendingStrip();
document.getElementById('att-preview').textContent = '';
lastPollSig = '';
lastServerTick = -1;
await loadConversationFromServer();
} else {
alert(d.detail || 'Senden fehlgeschlagen');
}
} catch (e) {
alert('Verbindungsfehler');
}
document.getElementById('btn-send').disabled = false;
}
function appendImagesToEl(parent, att) {
if (!att || !att.length) return;
att.forEach(function(a) {
if (!a || !a.data) return;
var mime = mimeForAttachment(a.name, a.mime || '');
var img = document.createElement('img');
img.src = 'data:' + mime + ';base64,' + a.data;
img.alt = a.name || 'Bild';
parent.appendChild(img);
});
}
function renderOne(m, isMe) {
var div = document.createElement('div');
div.className = 'msg ' + (isMe ? 'me' : 'them');
var meta = document.createElement('div');
meta.className = 'meta';
var core = (m.absender || '').split('(')[0].trim();
meta.textContent = core + ' · ' + (m.zeitstempel || m.empfangen || '');
div.appendChild(meta);
var body = messageBodyLines(m);
if (body) {
var t = document.createElement('div');
t.style.whiteSpace = 'pre-wrap';
t.textContent = body;
div.appendChild(t);
}
var att = m.extras && m.extras.attachments;
appendImagesToEl(div, att);
return div;
}
function playTone(idx) {
if (!soundEnabled) return;
var preset = TONE_PRESETS[idx] || TONE_PRESETS[0];
var vol = preset.vol * Math.max(0, volume);
try {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === 'suspended') audioCtx.resume();
var t0 = audioCtx.currentTime;
var t = t0;
preset.notes.forEach(function(note) {
var g = audioCtx.createGain();
g.connect(audioCtx.destination);
g.gain.setValueAtTime(vol, t);
g.gain.linearRampToValueAtTime(vol * 0.6, t + note.d * 0.4);
g.gain.exponentialRampToValueAtTime(0.001, t + note.d);
var o = audioCtx.createOscillator();
o.type = preset.wave;
o.frequency.setValueAtTime(note.f, t);
o.connect(g);
o.start(t);
o.stop(t + note.d + 0.05);
t += note.d;
});
} catch (e) {}
}
function updateSoundBtn() {
var btn = document.getElementById('cw-sound');
if (!btn) return;
if (soundEnabled) {
btn.innerHTML = '&#128276;';
btn.classList.remove('muted');
btn.title = 'Ton an (Klick = aus)';
} else {
btn.innerHTML = '&#128277;';
btn.classList.add('muted');
btn.title = 'Ton aus (Klick = an)';
}
}
function toggleSound() {
soundEnabled = !soundEnabled;
localStorage.setItem('empfang_sound', soundEnabled ? 'on' : 'off');
updateSoundBtn();
if (soundEnabled) playTone(currentToneIdx);
}
function testSound() {
var prev = soundEnabled;
soundEnabled = true;
playTone(currentToneIdx);
soundEnabled = prev;
}
function setVolFromSlider() {
var el = document.getElementById('cw-vol');
if (!el) return;
volume = Math.min(3, Math.max(0, parseInt(el.value, 10) / 100));
document.getElementById('cw-vol-disp').textContent = Math.round(volume * 100) + '%';
try {
localStorage.setItem('empfang_volume', String(volume));
} catch (e) {}
}
function initVolumeUI() {
var el = document.getElementById('cw-vol');
if (!el) return;
el.value = String(Math.round(volume * 100));
document.getElementById('cw-vol-disp').textContent = Math.round(volume * 100) + '%';
el.addEventListener('input', setVolFromSlider);
}
function initToneSelect() {
var sel = document.getElementById('cw-tone');
if (!sel) return;
sel.innerHTML = '';
TONE_PRESETS.forEach(function(t, i) {
var o = document.createElement('option');
o.value = String(i);
o.textContent = t.name;
if (i === currentToneIdx) o.selected = true;
sel.appendChild(o);
});
}
function saveToneChoice() {
var sel = document.getElementById('cw-tone');
currentToneIdx = parseInt(sel.value, 10) || 0;
localStorage.setItem('empfang_tone_idx', String(currentToneIdx));
if (soundEnabled) playTone(currentToneIdx);
}
function toggleSettings() {
var p = document.getElementById('cw-settings');
if (!p) return;
p.classList.toggle('hidden');
var g = document.getElementById('cw-gear');
if (g) g.classList.toggle('on', !p.classList.contains('hidden'));
}
async function loadConversationFromServer() {
if (!currentSession) return;
var me = encodeURIComponent(currentSession.display_name || '');
var aud = encodeURIComponent(conversationAudienceParam());
var qs = 'audience=' + aud + '&me=' + me;
var mu = String((currentSession.user_id || '')).trim();
if (mu) qs += '&me_user_id=' + encodeURIComponent(mu);
if (mode === 'dm' && peerUserId) qs += '&peer_user_id=' + encodeURIComponent(peerUserId);
try {
var r = await fetch(
API_BASE + '/conversation?' + qs,
{ credentials: 'include' }
);
if (r.status === 401) {
document.getElementById('gate').classList.remove('hidden');
return;
}
var d = await r.json();
var msgs = conversationSortChrono(d.messages || []);
lastConvMessages = msgs;
lastServerTick = parseInt(d.tick || 0, 10);
var sig = msgs.map(function(m) { return m.id; }).join(',');
if (lastPollSig && sig !== lastPollSig) {
var myName = currentSession.display_name;
var oldSet = {};
lastPollSig.split(',').forEach(function(id) { if (id) oldSet[id] = true; });
var playOnce = false;
msgs.forEach(function(m) {
if (oldSet[m.id]) return;
var isMe = (m.absender || '').indexOf(myName) === 0 ||
(m.absender || '').split('(')[0].trim() === myName;
if (!isMe) playOnce = true;
});
if (playOnce) playTone(currentToneIdx);
}
lastPollSig = sig;
var log = document.getElementById('log');
log.innerHTML = '';
var myName = currentSession.display_name;
var lastDay = '';
msgs.forEach(function(m) {
var tsFull = m.empfangen || m.zeitstempel || '';
var dk = conversationDayKey(tsFull);
if (dk && dk !== lastDay) {
lastDay = dk;
var sep = document.createElement('div');
sep.className = 'msg-date-sep';
var span = document.createElement('span');
span.textContent = conversationDateLabel(tsFull);
sep.appendChild(span);
log.appendChild(sep);
}
var isMe = (m.absender || '').indexOf(myName) === 0 ||
(m.absender || '').split('(')[0].trim() === myName;
log.appendChild(renderOne(m, isMe));
});
log.scrollTop = log.scrollHeight;
} catch (e) {}
}
function reloadThread() {
loadConversationFromServer();
}
async function init() {
parseQs();
setTitle();
initVolumeUI();
initToneSelect();
updateSoundBtn();
setupDropPaste();
var up = new URLSearchParams(window.location.search);
var invQ = (up.get('invite') || '').trim();
var resolvedMini = null;
if (invQ) {
try {
var ires = await fetch(API_BASE + '/auth/resolve_invite?code=' + encodeURIComponent(invQ));
resolvedMini = await ires.json().catch(function() { return null; });
if (resolvedMini && resolvedMini.valid && resolvedMini.practice_id) {
try { localStorage.setItem('aza_practice_id', resolvedMini.practice_id); } catch(e) {}
}
} catch(e) {}
}
currentSession = await checkAuth();
if (currentSession && invQ && resolvedMini && resolvedMini.valid && resolvedMini.practice_id &&
currentSession.practice_id && currentSession.practice_id !== resolvedMini.practice_id) {
try { await fetch(API_BASE + '/auth/logout', { method: 'POST', credentials: 'include' }); } catch(e) {}
currentSession = null;
try { localStorage.setItem('aza_practice_id', resolvedMini.practice_id); } catch(e) {}
}
if (!currentSession) {
showGate('Melden Sie sich im Empfang an, um zu chatten. Dieses Fenster nutzt dieselbe Anmeldung.');
return;
}
await loadPracticeSubline();
await loadConversationFromServer();
pollTimer = setInterval(async function() {
try {
var r = await fetch(API_BASE + '/pulse', { credentials: 'include' });
if (r.status !== 200) return;
var d = await r.json();
var t = parseInt(d.tick || 0, 10);
if (t !== lastServerTick) {
lastServerTick = t;
await loadConversationFromServer();
}
} catch (e) {}
}, 1000);
}
init();
</script>
</body>
</html>