- Nachrichten-Seite /messages ohne Login zugänglich (closes #11) - new_message/initial_messages an alle WS-Clients (broadcast statt broadcast_auth) - Dashboard: Nachrichten-Card entfernt, Links-Card (config.yaml) eingefügt - GET /api/links gibt konfigurierte Links aus config.yaml zurück - Nachrichten-Trenner: var(--bs-border-color) statt translucent - msgCount-Badge: bg-secondary-subtle/text-secondary-emphasis (theme-aware) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
174 lines
7.6 KiB
JavaScript
174 lines
7.6 KiB
JavaScript
// ── Channel color palette ──────────────────────────────────────
|
|
const CH_COLORS = [
|
|
{ bg: 'rgba(13,202,240,.12)', border: '#0dcaf0', text: '#0dcaf0' }, // 0 cyan
|
|
{ bg: 'rgba(25,135,84,.12)', border: '#198754', text: '#198754' }, // 1 green
|
|
{ bg: 'rgba(255,193,7,.12)', border: '#ffc107', text: '#d9a406' }, // 2 yellow
|
|
{ bg: 'rgba(220,53,69,.12)', border: '#dc3545', text: '#dc3545' }, // 3 red
|
|
{ bg: 'rgba(102,16,242,.12)', border: '#6610f2', text: '#6610f2' }, // 4 indigo
|
|
{ bg: 'rgba(13,110,253,.12)', border: '#0d6efd', text: '#0d6efd' }, // 5 blue
|
|
{ bg: 'rgba(32,201,151,.12)', border: '#20c997', text: '#20c997' }, // 6 teal
|
|
{ bg: 'rgba(253,126,20,.12)', border: '#fd7e14', text: '#fd7e14' }, // 7 orange
|
|
];
|
|
|
|
function chColor(chIdx) {
|
|
return CH_COLORS[chIdx % CH_COLORS.length] || CH_COLORS[0];
|
|
}
|
|
|
|
// ── State ──────────────────────────────────────────────────────
|
|
let nodes = {};
|
|
let channels = {};
|
|
let myNodeId = null;
|
|
let ws;
|
|
let msgChannelFilter = 'all';
|
|
let messages = [];
|
|
|
|
initPage({});
|
|
connectWebSocket();
|
|
|
|
// ── WebSocket ─────────────────────────────────────────────────
|
|
function connectWebSocket() {
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
ws = new WebSocket(`${proto}//${location.host}/ws`);
|
|
|
|
ws.onopen = () => {
|
|
document.getElementById('statusDot').classList.add('connected');
|
|
document.getElementById('statusText').textContent = 'Verbunden';
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
document.getElementById('statusDot').classList.remove('connected');
|
|
document.getElementById('statusText').textContent = 'Getrennt - Reconnect...';
|
|
setTimeout(connectWebSocket, 3000);
|
|
};
|
|
|
|
ws.onerror = () => { ws.close(); };
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data);
|
|
switch (msg.type) {
|
|
case 'initial':
|
|
msg.data.forEach(n => { nodes[n.node_id] = n; });
|
|
break;
|
|
case 'node_update':
|
|
nodes[msg.data.node_id] = msg.data;
|
|
break;
|
|
case 'channels':
|
|
channels = msg.data;
|
|
renderFilterBar();
|
|
break;
|
|
case 'my_node_id':
|
|
myNodeId = msg.data;
|
|
break;
|
|
case 'initial_messages':
|
|
messages = [];
|
|
document.getElementById('messagesList').innerHTML = '';
|
|
msg.data.reverse().forEach(m => addMessage(m));
|
|
break;
|
|
case 'new_message':
|
|
addMessage(msg.data);
|
|
break;
|
|
}
|
|
};
|
|
}
|
|
|
|
// ── Message rendering ─────────────────────────────────────────
|
|
function addMessage(msg) {
|
|
messages.push(msg);
|
|
if (messages.length > 300) messages.shift();
|
|
|
|
const isSent = myNodeId && msg.from_node === myNodeId;
|
|
const chIdx = msg.channel != null ? msg.channel : 0;
|
|
const chName = channels[chIdx] != null ? channels[chIdx] : `Ch ${chIdx}`;
|
|
const time = msg.timestamp ? new Date(msg.timestamp * 1000).toLocaleString('de-DE', {
|
|
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
|
|
}) : '';
|
|
const fromNode = nodes[msg.from_node];
|
|
const fromName = isSent ? 'Bot' : ((fromNode && fromNode.long_name) ? fromNode.long_name : (msg.from_node || '?'));
|
|
const fromId = isSent ? '' : msg.from_node || '';
|
|
const col = chColor(chIdx);
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'msg-full-item' + (isSent ? ' msg-full-sent' : '');
|
|
item.dataset.channel = String(chIdx);
|
|
|
|
if (msgChannelFilter !== 'all' && String(chIdx) !== msgChannelFilter) {
|
|
item.classList.add('d-none');
|
|
}
|
|
|
|
if (isSent) {
|
|
// Sent: right-aligned, green
|
|
item.innerHTML = `
|
|
<div class="msg-full-row msg-full-row-sent">
|
|
<div class="msg-full-meta text-end">
|
|
<small class="text-body-secondary">${time}</small>
|
|
<span class="msg-ch-badge" style="background:${col.bg};border-color:${col.border};color:${col.text}">${escapeHtml(chName)}</span>
|
|
<span class="fw-medium msg-full-name"><i class="bi bi-send-fill me-1" style="font-size:.7rem"></i>${escapeHtml(fromName)}</span>
|
|
</div>
|
|
<div class="msg-full-bubble msg-full-bubble-sent">${escapeHtml(msg.payload || '')}</div>
|
|
</div>`;
|
|
} else {
|
|
// Received: left-aligned, channel colored
|
|
item.innerHTML = `
|
|
<div class="msg-full-row">
|
|
<div class="msg-full-icon" style="background:${col.bg};border-color:${col.border}">
|
|
<i class="bi bi-person-fill" style="color:${col.text};font-size:.85rem"></i>
|
|
</div>
|
|
<div class="msg-full-body">
|
|
<div class="msg-full-meta">
|
|
<span class="fw-medium msg-full-name">${escapeHtml(fromName)}</span>
|
|
${fromId ? `<span class="text-body-secondary ms-1" style="font-size:.7rem">${escapeHtml(fromId)}</span>` : ''}
|
|
<span class="msg-ch-badge" style="background:${col.bg};border-color:${col.border};color:${col.text}">${escapeHtml(chName)}</span>
|
|
<small class="text-body-secondary ms-auto">${time}</small>
|
|
</div>
|
|
<div class="msg-full-bubble" style="border-left-color:${col.border}">${escapeHtml(msg.payload || '')}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
const list = document.getElementById('messagesList');
|
|
list.prepend(item);
|
|
updateCount();
|
|
}
|
|
|
|
function updateCount() {
|
|
const visible = document.getElementById('messagesList').querySelectorAll('.msg-full-item:not(.d-none)').length;
|
|
document.getElementById('msgCount').textContent = visible;
|
|
}
|
|
|
|
// ── Channel filter bar ────────────────────────────────────────
|
|
function renderFilterBar() {
|
|
const bar = document.getElementById('msgFilterBar');
|
|
const sorted = Object.entries(channels).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
|
|
const mkBtn = (ch, label, col) => {
|
|
const active = ch === msgChannelFilter;
|
|
const colStyle = col ? `background:${active ? col.border : 'transparent'};border-color:${col.border};color:${active ? '#fff' : col.text}` : '';
|
|
const cls = col ? '' : (active ? 'btn-secondary' : 'btn-outline-secondary');
|
|
return `<button class="btn btn-sm ${cls} py-0 px-2" data-ch="${escapeHtml(ch)}" style="font-size:.7rem;${colStyle}">${escapeHtml(label)}</button>`;
|
|
};
|
|
bar.innerHTML =
|
|
mkBtn('all', 'Alle', null) +
|
|
sorted.map(([idx, name]) => mkBtn(String(idx), name, chColor(parseInt(idx)))).join('');
|
|
bar.onclick = (e) => {
|
|
const btn = e.target.closest('button[data-ch]');
|
|
if (!btn) return;
|
|
msgChannelFilter = btn.dataset.ch;
|
|
renderFilterBar();
|
|
applyFilter();
|
|
};
|
|
}
|
|
|
|
function applyFilter() {
|
|
document.getElementById('messagesList').querySelectorAll('.msg-full-item').forEach(item => {
|
|
const vis = msgChannelFilter === 'all' || item.dataset.channel === msgChannelFilter;
|
|
item.classList.toggle('d-none', !vis);
|
|
});
|
|
updateCount();
|
|
}
|
|
|
|
// Clear button
|
|
document.getElementById('msgClearBtn').addEventListener('click', () => {
|
|
messages = [];
|
|
document.getElementById('messagesList').innerHTML = '';
|
|
updateCount();
|
|
});
|