// Application State const state = { authenticated: false, user: null, map: null, markers: {}, trails: {}, contactLastHeard: {}, contactLastColor: {}, refreshIntervals: [], timeUpdateInterval: null, lastDataReceived: Date.now(), serialConnected: false, connectionWarningShown: false, mapUserMoved: false, knownMessages: new Set(), knownContacts: new Map(), reconnectAttempts: 0, maxReconnectAttempts: 5, reconnectDelay: 5000, isReconnecting: false, contacts: [], msgFilter: 'all', // 'all' | 'direct' | 'channel' msgChannelIdx: 0 // which channel when filter='channel' }; // --- Auth error & reconnect helpers --- function handleAuthenticationError() { if (!state.authenticated) return; state.authenticated = false; state.user = null; stopRefreshIntervals(); alert('Your session has expired. Please log in again.'); ui.showScreen('login-screen'); } function handleConnectionLost() { if (state.isReconnecting) return; state.isReconnecting = true; if (!state.connectionWarningShown) { showNotification('Connection Lost', 'Attempting to reconnect to server...', 'warning'); state.connectionWarningShown = true; } attemptReconnect(); } async function attemptReconnect() { if (state.reconnectAttempts >= state.maxReconnectAttempts) { state.isReconnecting = false; showNotification('Connection Failed', 'Unable to reconnect. Please refresh the page.', 'error'); return; } state.reconnectAttempts++; await new Promise(r => setTimeout(r, state.reconnectDelay)); try { const response = await fetch('/api/auth/status', { credentials: 'include' }); if (response.ok) { state.reconnectAttempts = 0; state.isReconnecting = false; state.connectionWarningShown = false; showNotification('Connection Restored', 'Reconnected to server', 'success'); const activeTab = document.querySelector('.nav-tab.active')?.getAttribute('data-tab'); if (activeTab === 'overview') loadOverview(); if (activeTab === 'messages') loadMessages(); if (activeTab === 'contacts') loadContacts(); if (activeTab === 'map') loadMap(); } else if (response.status === 401) { handleAuthenticationError(); state.isReconnecting = false; } else { attemptReconnect(); } } catch { attemptReconnect(); } } // --- API Helper --- const api = { async request(url, options = {}) { try { const response = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...options.headers }, credentials: 'include' }); if (response.status === 401) { handleAuthenticationError(); throw new Error('Authentication required'); } if (!response.ok) { const err = await response.json().catch(() => ({ error: 'Request failed' })); throw new Error(err.error || 'Request failed'); } return response.json(); } catch (error) { if (error.message === 'Failed to fetch' || error.name === 'TypeError') handleConnectionLost(); throw error; } }, login: (username, password) => api.request('/api/login', { method: 'POST', body: JSON.stringify({ username, password }) }), logout: () => api.request('/api/logout', { method: 'POST' }), checkAuth: () => api.request('/api/auth/status'), getStats: () => api.request('/api/stats'), getContacts: () => api.request('/api/contacts'), getContact: (pubkey) => api.request(`/api/contacts/${pubkey}`), getPositions: () => api.request('/api/positions'), getPositionTrails: (limit = 10) => api.request(`/api/positions/trails/all?limit=${limit}`), getMessages: (limit = 200, type = null, channelIdx = null) => { let url = `/api/messages?limit=${limit}`; if (type) url += `&type=${type}`; if (channelIdx != null) url += `&channel_idx=${channelIdx}`; return api.request(url); }, sendMessage: (payload) => api.request('/api/messages/send', { method: 'POST', body: JSON.stringify(payload) }), purgeData: (hours) => api.request('/api/purge', { method: 'POST', body: JSON.stringify({ hours }) }), getSerialStatus: () => api.request('/api/serial/status'), getSerialPorts: () => api.request('/api/serial/ports'), getDevice: () => api.request('/api/device'), getChannels: () => api.request('/api/channels') }; // --- UI Helper --- const ui = { showScreen(id) { document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden')); document.getElementById(id).classList.remove('hidden'); }, showTab(tabName) { document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); document.querySelector(`[data-tab="${tabName}"]`)?.classList.add('active'); document.getElementById(`${tabName}-tab`)?.classList.add('active'); if (tabName === 'map') { setTimeout(() => { if (state.map) state.map.invalidateSize(); else initMap(); }, 50); } }, showError(id, msg) { const el = document.getElementById(id); if (el) { el.textContent = msg; el.style.display = 'block'; } }, hideError(id) { const el = document.getElementById(id); if (el) { el.textContent = ''; el.style.display = 'none'; } }, formatRelativeTime(dateString) { if (!dateString) return 'Never'; let str = dateString; if (typeof str === 'number') return ui.formatRelativeTime(new Date(str * 1000).toISOString()); if (str.includes(' ') && !str.includes('T')) str = str.replace(' ', 'T') + 'Z'; const date = new Date(str); if (isNaN(date.getTime())) return 'Unknown'; const diff = Math.floor((Date.now() - date) / 1000); if (diff < 1) return 'just now'; if (diff < 60) return `${diff}s ago`; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; return date.toLocaleDateString(); }, formatBytes(bytes) { if (!bytes) return '0 B'; const k = 1024, sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; }, getMarkerColor(lastHeardDate, id) { if (!lastHeardDate) return '#a50026'; let str = lastHeardDate; if (str.includes(' ') && !str.includes('T')) str = str.replace(' ', 'T') + 'Z'; const mins = (Date.now() - new Date(str)) / 60000; const colors = ['#00ff00','#9db800','#cccc00','#fee08b','#fdae61','#f46d43','#d73027','#a50026']; const bucketSize = 360 / 8; const idx = Math.min(7, Math.floor(mins / bucketSize)); const newColor = colors[idx]; if (id && state.contactLastColor[id]) { const lastIdx = colors.indexOf(state.contactLastColor[id]); if (lastIdx !== -1 && lastIdx !== idx) { const minsIntoBucket = mins % bucketSize; if (Math.min(minsIntoBucket, bucketSize - minsIntoBucket) < 2) return state.contactLastColor[id]; } } if (id) state.contactLastColor[id] = newColor; return newColor; }, getContactTypeName(type) { return ['None', 'Chat', 'Repeater', 'Room', 'Sensor'][type] || 'Unknown'; }, getContactTypeBadgeClass(type) { return ['', 'type-chat', 'type-repeater', 'type-room', 'type-sensor'][type] || ''; }, formatPathLength(pl) { if (pl === 255 || pl === 0xFF) return 'Flood'; if (pl === 0) return 'Direct'; return `${pl} hop${pl !== 1 ? 's' : ''}`; }, formatSNR(snr) { if (snr == null) return '—'; return `${snr.toFixed(1)} dB`; }, hashStringToColor(str) { if (!str) return '#2563eb'; let hash = 0; for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash) | 0; const hue = Math.abs(hash % 360); return `hsl(${hue}, 65%, 45%)`; } }; // --- Notification --- function showNotification(title, message, type = 'info', duration = 4000) { const existing = document.querySelector('.notification'); if (existing) existing.remove(); const el = document.createElement('div'); el.className = `notification ${type}`; el.innerHTML = `${title}${message ? `
${message}` : ''}`; document.body.appendChild(el); setTimeout(() => el.remove(), duration); } // --- Login / Logout --- document.getElementById('login-form')?.addEventListener('submit', async (e) => { e.preventDefault(); ui.hideError('login-error'); try { const result = await api.login( document.getElementById('username').value, document.getElementById('password').value ); if (result.success) { state.authenticated = true; state.user = result.user; initDashboard(); } } catch (error) { ui.showError('login-error', error.message); } }); document.getElementById('logout-btn')?.addEventListener('click', async () => { try { await api.logout(); } catch {} state.authenticated = false; state.user = null; stopRefreshIntervals(); ui.showScreen('login-screen'); }); // --- Tab Navigation --- document.querySelectorAll('.nav-tab').forEach(tab => { tab.addEventListener('click', () => { const name = tab.getAttribute('data-tab'); ui.showTab(name); if (name === 'overview') loadOverview(); if (name === 'map') loadMap(); if (name === 'messages') loadMessages(); if (name === 'contacts') loadContacts(); if (name === 'settings') loadSettings(); }); }); // --- Dashboard Init --- async function initDashboard() { ui.showScreen('dashboard-screen'); document.getElementById('user-info').textContent = state.user.username; // Load device info for header badge try { const device = await api.getDevice(); if (device.available && device.name) { const badge = document.getElementById('device-name-badge'); badge.textContent = device.name; badge.classList.remove('hidden'); } } catch {} // Populate contact selector for message sending populateContactSelector(); // Load initial tab and pre-fetch messages loadOverview(); loadMessages(); // pre-load so they're ready when tab is opened startRefreshIntervals(); } function startRefreshIntervals() { stopRefreshIntervals(); // Serial status every 5s const statusInterval = setInterval(async () => { try { const status = await api.getSerialStatus(); updateSerialStatus(status.connected); } catch {} }, 5000); state.refreshIntervals.push(statusInterval); // Data auto-refresh every 10s const overviewInterval = setInterval(() => { const activeTab = document.querySelector('.nav-tab.active')?.getAttribute('data-tab'); if (activeTab === 'overview') loadOverview(); if (activeTab === 'messages') loadMessages(); if (activeTab === 'map' && !state.mapUserMoved) loadMap(); if (activeTab === 'contacts') loadContacts(); }, 10000); state.refreshIntervals.push(overviewInterval); // Update relative timestamps every second state.timeUpdateInterval = setInterval(updateDataTimer, 1000); state.refreshIntervals.push(state.timeUpdateInterval); } function stopRefreshIntervals() { state.refreshIntervals.forEach(id => clearInterval(id)); state.refreshIntervals = []; } function updateSerialStatus(connected) { state.serialConnected = connected; const el = document.getElementById('serial-status'); const dot = el?.querySelector('.status-dot'); if (dot) dot.classList.toggle('connected', connected); if (el) el.title = connected ? 'USB Serial: Connected' : 'USB Serial: Disconnected'; } function updateDataTimer() { const diff = Math.floor((Date.now() - state.lastDataReceived) / 1000); const el = document.getElementById('data-timer'); if (!el) return; if (diff < 60) el.textContent = `${diff}s ago`; else if (diff < 3600) el.textContent = `${Math.floor(diff / 60)}m ago`; else el.textContent = `${Math.floor(diff / 3600)}h ago`; } async function populateContactSelector() { try { const contacts = await api.getContacts(); state.contacts = contacts; const sel = document.getElementById('message-to-contact'); if (!sel) return; sel.innerHTML = ''; contacts.forEach(c => { const opt = document.createElement('option'); opt.value = c.pubkey; const prefix = c.pubkey ? c.pubkey.substring(0, 8) + '...' : ''; opt.textContent = c.name ? `${c.name} (${prefix})` : prefix; sel.appendChild(opt); }); } catch (err) { console.error('Error loading contacts for selector:', err); } } // Toggle direct/channel selector visibility document.querySelectorAll('input[name="msg-type"]').forEach(radio => { radio.addEventListener('change', () => { const isDirect = document.getElementById('type-direct').checked; document.getElementById('message-to-contact').classList.toggle('hidden', !isDirect); document.getElementById('message-channel-idx').classList.toggle('hidden', isDirect); }); }); // --- Overview --- async function loadOverview() { try { const [stats, device, messages] = await Promise.all([ api.getStats(), api.getDevice().catch(() => ({ available: false })), api.getMessages(10).catch(() => []) ]); state.lastDataReceived = Date.now(); document.getElementById('stat-contacts').textContent = stats.contacts || 0; document.getElementById('stat-messages').textContent = stats.messages || 0; document.getElementById('stat-positions').textContent = stats.positions || 0; document.getElementById('stat-db-size').textContent = ui.formatBytes(stats.databaseSize || 0); updateSerialStatus(stats.serialConnected); // Device info panel if (device.available) { const panel = document.getElementById('device-info-panel'); panel.classList.remove('hidden'); const grid = document.getElementById('device-info-content'); grid.innerHTML = ''; const items = [ { label: 'Name', value: device.name || '—' }, { label: 'Pubkey Prefix', value: device.pubkeyPrefix || '—' }, { label: 'TX Power', value: device.txPower != null ? `${device.txPower} dBm` : '—' }, { label: 'Frequency', value: device.frequency ? `${(device.frequency / 1e6).toFixed(3)} MHz` : '—' }, ]; if (device.latitude != null) { items.push({ label: 'Latitude', value: device.latitude.toFixed(6) }); items.push({ label: 'Longitude', value: device.longitude.toFixed(6) }); } items.forEach(({ label, value }) => { grid.innerHTML += `
${label}
${value}
`; }); } renderRecentMessages(messages, 'recent-messages'); } catch (err) { console.error('Error loading overview:', err); } } // Parse channel message text: MeshCore sends "SenderName: message body" // Returns { sender, body } — if no colon pattern, sender is null and body is the full text function parseChannelText(text) { if (!text) return { sender: null, body: '' }; // Look for "Name: message" — name can include spaces, colon must be followed by space const match = text.match(/^([^:]{1,32}):\s(.+)$/s); if (match) return { sender: match[1].trim(), body: match[2] }; return { sender: null, body: text }; } function renderRecentMessages(messages, containerId) { const container = document.getElementById(containerId); if (!container) return; if (!messages.length) { container.innerHTML = '
💬

No messages yet

'; return; } container.innerHTML = messages.map(msg => { const isChannel = msg.msg_type === 1; const isOutbound = msg.from_pubkey === 'self'; let displayFrom, displayText; if (isOutbound) { displayFrom = 'You'; displayText = msg.text || ''; } else if (isChannel) { // Channel messages include sender name in text: "SenderName: body" const parsed = parseChannelText(msg.text); displayFrom = parsed.sender || msg.from_name || `Ch ${msg.channel_idx}`; displayText = parsed.body; } else { // Direct message — from_name comes from contacts JOIN displayFrom = msg.from_name || `${msg.from_pubkey?.substring(0, 12) || '?'}…`; displayText = msg.text || ''; } const channelLabel = isChannel ? (msg.channel_idx === 0 ? 'Public' : `Ch ${msg.channel_idx}`) : null; const channelBadge = isChannel ? `${channelLabel}` : `Direct`; const deliveryBadge = isOutbound && msg.delivered ? 'Delivered' : ''; const pathInfo = msg.path_length != null ? `${ui.formatPathLength(msg.path_length)}` : ''; return `
${escapeHtml(displayFrom)} ${ui.formatRelativeTime(msg.created_at)}
${escapeHtml(displayText)}
${channelBadge} ${deliveryBadge} ${pathInfo ? `${pathInfo}` : ''}
`; }).join(''); } // --- Map --- function initMap() { if (state.map) return; state.map = L.map('map').setView([0, 0], 2); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(state.map); state.map.on('dragstart zoomstart', () => { state.mapUserMoved = true; }); document.getElementById('refresh-map-btn')?.addEventListener('click', () => { state.mapUserMoved = false; loadMap(); }); } async function loadMap() { initMap(); try { const [positions, trails] = await Promise.all([ api.getPositions(), api.getPositionTrails(10).catch(() => []) ]); state.lastDataReceived = Date.now(); // Clear old markers Object.values(state.markers).forEach(m => state.map.removeLayer(m)); state.markers = {}; Object.values(state.trails).forEach(t => state.map.removeLayer(t)); state.trails = {}; const bounds = []; // Draw trails const trailsByPubkey = {}; trails.forEach(p => { if (!trailsByPubkey[p.pubkey]) trailsByPubkey[p.pubkey] = []; trailsByPubkey[p.pubkey].push([p.latitude, p.longitude]); }); Object.entries(trailsByPubkey).forEach(([pubkey, pts]) => { if (pts.length > 1) { state.trails[pubkey] = L.polyline(pts, { color: '#94a3b8', weight: 2, opacity: 0.6 }).addTo(state.map); } }); // Draw markers positions.forEach(pos => { if (!pos.latitude || !pos.longitude) return; const latlng = [pos.latitude, pos.longitude]; bounds.push(latlng); const color = ui.getMarkerColor(pos.last_heard, pos.pubkey); const name = pos.name || pos.pubkey?.substring(0, 8) || 'Unknown'; const icon = L.divIcon({ className: '', html: `
${name.charAt(0).toUpperCase()}
`, iconSize: [24, 24], iconAnchor: [12, 12] }); const marker = L.marker(latlng, { icon }) .addTo(state.map) .bindPopup(` ${escapeHtml(name)}
Type: ${ui.getContactTypeName(pos.contact_type || 0)}
Last heard: ${ui.formatRelativeTime(pos.last_heard)}
${pos.latitude.toFixed(6)}, ${pos.longitude.toFixed(6)} `); state.markers[pos.pubkey] = marker; }); if (bounds.length > 0 && !state.mapUserMoved) { if (bounds.length === 1) state.map.setView(bounds[0], 12); else state.map.fitBounds(bounds, { padding: [30, 30] }); } } catch (err) { console.error('Error loading map:', err); } } // --- Messages --- async function loadMessages() { try { let type = null, channelIdx = null; if (state.msgFilter === 'direct') type = 'direct'; if (state.msgFilter === 'channel') { type = 'channel'; channelIdx = state.msgChannelIdx; } const messages = await api.getMessages(200, type, channelIdx); state.lastDataReceived = Date.now(); renderRecentMessages(messages, 'message-history'); } catch (err) { console.error('Error loading messages:', err); } } document.getElementById('refresh-messages-btn')?.addEventListener('click', loadMessages); // Wire up filter buttons document.getElementById('msg-filter-tabs')?.addEventListener('click', (e) => { const btn = e.target.closest('.msg-filter-btn'); if (!btn) return; document.querySelectorAll('.msg-filter-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const filter = btn.dataset.filter; state.msgFilter = filter; state.msgChannelIdx = filter === 'channel' ? parseInt(btn.dataset.ch || 0) : 0; loadMessages(); }); document.getElementById('send-message-form')?.addEventListener('submit', async (e) => { e.preventDefault(); const text = document.getElementById('message-text').value.trim(); if (!text) return; const isDirect = document.getElementById('type-direct').checked; const btn = e.target.querySelector('button[type="submit"]'); btn.disabled = true; btn.textContent = 'Sending...'; try { let payload; if (isDirect) { const toPubkey = document.getElementById('message-to-contact').value; if (!toPubkey) { showNotification('Error', 'Select a contact', 'error'); return; } payload = { type: 'direct', to_pubkey: toPubkey, text }; } else { const channelIdx = parseInt(document.getElementById('message-channel-idx').value); payload = { type: 'channel', channel_idx: channelIdx, text }; } await api.sendMessage(payload); document.getElementById('message-text').value = ''; showNotification('Sent', 'Message sent successfully', 'success', 2000); loadMessages(); } catch (err) { showNotification('Error', err.message, 'error'); } finally { btn.disabled = false; btn.textContent = 'Send'; } }); // --- Contacts --- async function loadContacts() { try { const contacts = await api.getContacts(); state.lastDataReceived = Date.now(); state.contacts = contacts; renderContacts(contacts); } catch (err) { console.error('Error loading contacts:', err); } } document.getElementById('refresh-contacts-btn')?.addEventListener('click', loadContacts); function renderContacts(contacts) { const grid = document.getElementById('contacts-grid'); if (!grid) return; if (!contacts.length) { grid.innerHTML = '
📡

No contacts found

Connect a MeshCore device to discover contacts.

'; return; } grid.innerHTML = contacts.map(c => { const name = c.name || c.pubkey?.substring(0, 8) || 'Unknown'; const prefix = c.pubkey ? c.pubkey.substring(0, 12) : ''; const typeClass = ui.getContactTypeBadgeClass(c.contact_type); const typeName = ui.getContactTypeName(c.contact_type); const isOnline = c.last_heard && (Date.now() - new Date(c.last_heard.includes('T') ? c.last_heard : c.last_heard.replace(' ', 'T') + 'Z')) < 3600000; const isFav = (c.flags & 1) === 1; return `
${isFav ? '★ ' : ''}${escapeHtml(name)}
${prefix}…
${isOnline ? 'Active' : 'Inactive'} ${c.contact_type > 0 ? `${typeName}` : ''}
Last heard${ui.formatRelativeTime(c.last_heard)}
Path${ui.formatPathLength(c.path_length)}
${c.battery_mv ? `
Battery${c.battery_mv} mV
` : ''} ${c.latitude != null ? `
GPS${c.latitude.toFixed(4)}, ${c.longitude.toFixed(4)}
` : ''}
`; }).join(''); } async function showContactModal(pubkey) { try { const contact = await api.getContact(pubkey); const modal = document.getElementById('contact-modal'); const detail = document.getElementById('contact-detail'); const rows = [ { label: 'Name', value: contact.name || '—' }, { label: 'Public Key', value: contact.pubkey }, { label: 'Type', value: ui.getContactTypeName(contact.contact_type) }, { label: 'Path Length', value: ui.formatPathLength(contact.path_length) }, { label: 'Flags', value: (contact.flags & 1) ? 'Favourite' : 'Normal' }, { label: 'Last Heard', value: ui.formatRelativeTime(contact.last_heard) }, { label: 'Last Advert', value: contact.last_advert ? ui.formatRelativeTime(new Date(contact.last_advert * 1000).toISOString()) : '—' }, { label: 'Battery', value: contact.battery_mv ? `${contact.battery_mv} mV` : '—' }, { label: 'Uptime', value: contact.uptime_seconds ? formatUptime(contact.uptime_seconds) : '—' }, ]; if (contact.latitude != null) { rows.push({ label: 'Latitude', value: contact.latitude.toFixed(6) }); rows.push({ label: 'Longitude', value: contact.longitude.toFixed(6) }); } detail.innerHTML = rows.map(r => `
${r.label}${escapeHtml(String(r.value))}
` ).join(''); modal.classList.remove('hidden'); } catch (err) { console.error('Error loading contact details:', err); } } document.querySelector('#contact-modal .modal-close')?.addEventListener('click', () => { document.getElementById('contact-modal').classList.add('hidden'); }); document.getElementById('contact-modal')?.addEventListener('click', (e) => { if (e.target === e.currentTarget) e.currentTarget.classList.add('hidden'); }); function formatUptime(seconds) { if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`; } // --- Settings --- async function loadSettings() { try { const status = await api.getSerialStatus(); const portInfo = document.getElementById('port-info'); if (portInfo) { portInfo.innerHTML = `
Port${status.port}
Status${status.connected ? 'Connected' : 'Disconnected'}
${status.deviceName ? `
Device${escapeHtml(status.deviceName)}
` : ''} `; } const ports = await api.getSerialPorts().catch(() => []); const portsList = document.getElementById('available-ports'); if (portsList) { if (ports.length === 0) { portsList.innerHTML = '

No serial ports detected.

'; } else { portsList.innerHTML = '

Available serial ports:

' + ports.map(p => `
${escapeHtml(p.path)} ${p.manufacturer ? `${escapeHtml(p.manufacturer)}` : ''}
`).join(''); } } } catch (err) { console.error('Error loading settings:', err); } } document.getElementById('purge-form')?.addEventListener('submit', async (e) => { e.preventDefault(); const hours = parseInt(document.getElementById('purge-hours').value); if (!confirm(`Delete all data older than ${hours < 24 ? hours + ' hour(s)' : hours / 24 + ' day(s)'}?`)) return; try { const result = await api.purgeData(hours); showNotification('Purge Complete', `Deleted: ${result.deleted.messages} messages, ${result.deleted.positions} positions, ${result.deleted.contacts} contacts`, 'success', 5000); } catch (err) { showNotification('Purge Failed', err.message, 'error'); } }); // --- Utility --- function escapeHtml(str) { if (str == null) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // --- Init on page load --- (async () => { try { const auth = await api.checkAuth(); if (auth.authenticated) { state.authenticated = true; state.user = auth.user; initDashboard(); } else { ui.showScreen('login-screen'); } } catch { ui.showScreen('login-screen'); } })();