'use strict';
const App = (() => {
// ── State ──────────────────────────────────────────────────────────────────
let currentPage = 'dashboard';
let editingWebhookId = null;
const PAGE_SIZE = 20;
let receivedOffset = 0;
let sentOffset = 0;
// ── API helper ─────────────────────────────────────────────────────────────
async function api(method, path, body) {
const opts = {
method,
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
};
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch('/api' + path, opts);
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
// ── Toast ──────────────────────────────────────────────────────────────────
function toast(msg, type = 'info') {
const el = document.createElement('div');
el.className = `toast toast-${type}`;
const icons = { success: '✅', error: '❌', info: 'ℹ️' };
el.innerHTML = `${icons[type] || ''}${msg}`;
document.getElementById('toast-container').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function showAlert(id, msg, type = 'danger') {
const el = document.getElementById(id);
if (!el) return;
el.className = `alert alert-${type}`;
el.textContent = msg;
el.style.display = 'flex';
}
function hideAlert(id) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
}
// ── Formatters ─────────────────────────────────────────────────────────────
function fmt(dateStr) {
if (!dateStr) return '—';
try {
return new Date(dateStr).toLocaleString();
} catch { return dateStr; }
}
function statusBadge(status) {
const map = {
sent: ['badge-blue', 'Sent'],
received: ['badge-green', 'Received'],
failed: ['badge-red', 'Failed'],
pending: ['badge-yellow', 'Pending'],
};
const [cls, label] = map[status] || ['badge-gray', status];
return `${label}`;
}
function esc(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ── Navigation ─────────────────────────────────────────────────────────────
function navigate(page) {
currentPage = page;
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById(`page-${page}`)?.classList.add('active');
document.querySelector(`.nav-item[data-page="${page}"]`)?.classList.add('active');
// Load data for page
const loaders = {
dashboard: loadDashboard,
received: loadReceived,
sent: loadSent,
webhooks: loadWebhooks,
tokens: loadTokens,
settings: loadSettings,
};
loaders[page]?.();
}
// ── Auth ───────────────────────────────────────────────────────────────────
async function checkAuth() {
try {
const me = await api('GET', '/auth/me');
if (me.authenticated) {
showApp(me.username);
loadDashboard();
} else {
showLogin();
}
} catch {
showLogin();
}
}
function showLogin() {
document.getElementById('login-page').classList.add('active');
document.getElementById('sidebar').style.display = 'none';
document.getElementById('main').style.display = 'none';
}
function showApp(username) {
document.getElementById('login-page').classList.remove('active');
document.getElementById('sidebar').style.display = 'flex';
document.getElementById('main').style.display = 'flex';
document.getElementById('user-name').textContent = username || 'Admin';
document.getElementById('user-avatar').textContent = (username || 'A')[0].toUpperCase();
}
async function login(e) {
e.preventDefault();
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
hideAlert('login-error');
try {
await api('POST', '/auth/login', { username, password });
showApp(username);
loadDashboard();
} catch (err) {
showAlert('login-error', err.message || 'Login failed');
}
}
async function logout() {
await api('POST', '/auth/logout');
showLogin();
}
// ── Dashboard ──────────────────────────────────────────────────────────────
async function loadDashboard() {
try {
const [stats, msgs] = await Promise.all([
api('GET', '/stats'),
api('GET', '/sms/messages?limit=10'),
]);
document.getElementById('stat-sent').textContent = stats.sent ?? 0;
document.getElementById('stat-received').textContent = stats.received ?? 0;
document.getElementById('stat-failed').textContent = stats.failed ?? 0;
const tbody = document.getElementById('recent-messages');
if (!msgs.length) {
tbody.innerHTML = '
|
';
} else {
tbody.innerHTML = msgs.map(m => `
| ${m.direction === 'received' ? '📥' : '📤'} |
${esc(m.phone_number)} |
${esc(m.message)} |
${statusBadge(m.status)} |
${fmt(m.created_at)} |
`).join('');
}
} catch (err) {
toast('Failed to load dashboard: ' + err.message, 'error');
}
}
function refreshDashboard() { loadDashboard(); }
// ── Received ───────────────────────────────────────────────────────────────
async function loadReceived(offset = 0) {
receivedOffset = offset;
const tbody = document.getElementById('received-list');
tbody.innerHTML = ' |
';
try {
const msgs = await api('GET', `/sms/messages/received?limit=${PAGE_SIZE}&offset=${offset}`);
if (!msgs.length) {
tbody.innerHTML = ' |
';
} else {
tbody.innerHTML = msgs.map(m => `
| ${esc(m.phone_number)} |
${esc(m.message)} |
${fmt(m.created_at)} |
`).join('');
}
renderPagination('received-pagination', offset, msgs.length, loadReceived);
} catch (err) {
toast('Failed to load received messages: ' + err.message, 'error');
}
}
// ── Sent ───────────────────────────────────────────────────────────────────
async function loadSent(offset = 0) {
sentOffset = offset;
const tbody = document.getElementById('sent-list');
tbody.innerHTML = ' |
';
try {
const msgs = await api('GET', `/sms/messages/sent?limit=${PAGE_SIZE}&offset=${offset}`);
if (!msgs.length) {
tbody.innerHTML = ' |
';
} else {
tbody.innerHTML = msgs.map(m => `
| ${esc(m.phone_number)} |
${esc(m.message)} |
${statusBadge(m.status)} |
${fmt(m.created_at)} |
`).join('');
}
renderPagination('sent-pagination', offset, msgs.length, loadSent);
} catch (err) {
toast('Failed to load sent messages: ' + err.message, 'error');
}
}
function renderPagination(id, offset, count, loader) {
const el = document.getElementById(id);
if (!el) return;
const hasPrev = offset > 0;
const hasNext = count === PAGE_SIZE;
el.innerHTML = `
${hasPrev ? `` : ''}
Page ${Math.floor(offset / PAGE_SIZE) + 1}
${hasNext ? `` : ''}
`;
}
function _paginate(paginationId, offset) {
if (paginationId === 'received-pagination') loadReceived(offset);
else if (paginationId === 'sent-pagination') loadSent(offset);
}
// ── Send SMS ───────────────────────────────────────────────────────────────
async function sendSMS(e) {
e.preventDefault();
const number = document.getElementById('send-number').value.trim();
const message = document.getElementById('send-message').value.trim();
hideAlert('send-alert');
if (!number || !message) {
showAlert('send-alert', 'Phone number and message are required');
return;
}
const btn = document.getElementById('send-btn');
btn.disabled = true;
btn.textContent = 'Sending…';
try {
await api('POST', '/sms/send', { number, message });
toast(`Message sent to ${number}`, 'success');
document.getElementById('send-number').value = '';
document.getElementById('send-message').value = '';
document.getElementById('char-count').textContent = '0';
} catch (err) {
showAlert('send-alert', 'Failed to send: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = '✉️ Send Message';
}
}
async function triggerPoll() {
try {
await api('POST', '/sms/poll');
toast('Device polled for new messages', 'success');
if (currentPage === 'received') loadReceived(receivedOffset);
} catch (err) {
toast('Poll failed: ' + err.message, 'error');
}
}
// ── Webhooks ───────────────────────────────────────────────────────────────
async function loadWebhooks() {
const tbody = document.getElementById('webhook-list');
try {
const hooks = await api('GET', '/webhooks');
if (!hooks.length) {
tbody.innerHTML = ' |
';
} else {
tbody.innerHTML = hooks.map(h => {
const hookUrl = `${window.location.origin}/api/hooks/${h.token}`;
return `
| ${esc(h.name)} |
${esc(h.phone_number)} |
${esc(hookUrl)}
|
|
|
`;
}).join('');
}
} catch (err) {
toast('Failed to load webhooks: ' + err.message, 'error');
}
}
function copyWebhookUrl(url) {
navigator.clipboard?.writeText(url).then(() => toast('Webhook URL copied', 'success'));
}
function openWebhookModal(id) {
editingWebhookId = id || null;
document.getElementById('webhook-modal-title').textContent = id ? 'Edit Webhook' : 'Add Webhook';
['wh-name', 'wh-phone', 'wh-template'].forEach(f => document.getElementById(f).value = '');
if (id) {
api('GET', '/webhooks').then(hooks => {
const h = hooks.find(x => x.id === id);
if (!h) return;
document.getElementById('wh-name').value = h.name || '';
document.getElementById('wh-phone').value = h.phone_number || '';
document.getElementById('wh-template').value = h.message_template || '{{msg}}';
});
}
openModal('webhook-modal');
}
async function saveWebhook() {
const name = document.getElementById('wh-name').value.trim();
const phone_number = document.getElementById('wh-phone').value.trim();
const message_template = document.getElementById('wh-template').value.trim() || '{{msg}}';
if (!name || !phone_number) { toast('Name and phone number are required', 'error'); return; }
const payload = { name, phone_number, message_template };
try {
if (editingWebhookId) {
await api('PUT', `/webhooks/${editingWebhookId}`, payload);
toast('Webhook updated', 'success');
} else {
await api('POST', '/webhooks', payload);
toast('Webhook created', 'success');
}
closeModal('webhook-modal');
loadWebhooks();
} catch (err) {
toast('Save failed: ' + err.message, 'error');
}
}
async function toggleWebhook(id, active) {
try {
await api('PUT', `/webhooks/${id}`, { active: active ? 1 : 0 });
} catch (err) {
toast('Update failed: ' + err.message, 'error');
}
}
async function deleteWebhook(id) {
if (!confirm('Delete this webhook?')) return;
try {
await api('DELETE', `/webhooks/${id}`);
toast('Webhook deleted', 'success');
loadWebhooks();
} catch (err) {
toast('Delete failed: ' + err.message, 'error');
}
}
// ── API Tokens ─────────────────────────────────────────────────────────────
async function loadTokens() {
const tbody = document.getElementById('token-list');
try {
const tokens = await api('GET', '/tokens');
if (!tokens.length) {
tbody.innerHTML = ' |
';
} else {
tbody.innerHTML = tokens.map(t => `
| ${esc(t.name)} |
•••••••••••••••• |
|
${fmt(t.created_at)} |
|
`).join('');
}
} catch (err) {
toast('Failed to load tokens: ' + err.message, 'error');
}
}
function openTokenModal() {
document.getElementById('token-form').style.display = 'block';
document.getElementById('token-created').style.display = 'none';
document.getElementById('token-create-btn').style.display = 'inline-flex';
document.getElementById('tk-name').value = '';
openModal('token-modal');
}
async function createToken() {
const name = document.getElementById('tk-name').value.trim();
if (!name) { toast('Token name is required', 'error'); return; }
try {
const tok = await api('POST', '/tokens', { name });
document.getElementById('new-token-value').textContent = tok.token;
document.getElementById('token-form').style.display = 'none';
document.getElementById('token-created').style.display = 'block';
document.getElementById('token-create-btn').style.display = 'none';
loadTokens();
} catch (err) {
toast('Failed to create token: ' + err.message, 'error');
}
}
function copyToken() {
const val = document.getElementById('new-token-value').textContent;
navigator.clipboard?.writeText(val).then(() => toast('Copied to clipboard', 'success'));
}
async function toggleApiToken(id, active) {
try {
await api('PATCH', `/tokens/${id}`, { active });
} catch (err) {
toast('Update failed: ' + err.message, 'error');
}
}
async function deleteToken(id) {
if (!confirm('Delete this token? Any apps using it will lose access.')) return;
try {
await api('DELETE', `/tokens/${id}`);
toast('Token deleted', 'success');
loadTokens();
} catch (err) {
toast('Delete failed: ' + err.message, 'error');
}
}
// ── Settings ───────────────────────────────────────────────────────────────
async function loadSettings() {
try {
const [cfg, logRes] = await Promise.all([
api('GET', '/config'),
api('GET', '/log-level'),
]);
document.getElementById('cfg-ip').value = cfg.device_ip || '';
document.getElementById('cfg-username').value = cfg.device_username || '';
document.getElementById('cfg-password').value = cfg.device_password || '';
document.getElementById('cfg-modem').value = cfg.device_modem || '';
document.getElementById('cfg-poll').value = cfg.poll_interval || '30';
document.getElementById('cfg-log-level').value = logRes.level || 'INFO';
} catch (err) {
toast('Failed to load settings: ' + err.message, 'error');
}
}
async function saveLogLevel() {
hideAlert('log-alert');
const level = document.getElementById('cfg-log-level').value;
try {
const result = await api('PUT', '/log-level', { level });
showAlert('log-alert', `Log level set to ${result.level}`, 'success');
} catch (err) {
showAlert('log-alert', 'Failed: ' + err.message);
}
}
async function saveConfig() {
hideAlert('settings-alert');
const body = {
device_ip: document.getElementById('cfg-ip').value.trim(),
device_username: document.getElementById('cfg-username').value.trim(),
device_password: document.getElementById('cfg-password').value,
device_modem: document.getElementById('cfg-modem').value.trim(),
poll_interval: document.getElementById('cfg-poll').value,
};
try {
await api('PUT', '/config', body);
showAlert('settings-alert', 'Settings saved successfully', 'success');
toast('Settings saved', 'success');
} catch (err) {
showAlert('settings-alert', 'Save failed: ' + err.message);
}
}
async function testConnection() {
hideAlert('settings-alert');
showAlert('settings-alert', 'Testing connection…', 'info');
try {
const result = await api('POST', '/config/test');
if (result.success) {
showAlert('settings-alert', '✅ Connection successful!', 'success');
} else {
showAlert('settings-alert', '❌ Connection failed: ' + result.error);
}
} catch (err) {
showAlert('settings-alert', 'Test failed: ' + err.message);
}
}
async function changePassword() {
hideAlert('pw-alert');
const currentPassword = document.getElementById('pw-current').value;
const newPassword = document.getElementById('pw-new').value;
const confirm = document.getElementById('pw-confirm').value;
if (newPassword !== confirm) {
showAlert('pw-alert', 'Passwords do not match');
return;
}
if (newPassword.length < 6) {
showAlert('pw-alert', 'Password must be at least 6 characters');
return;
}
try {
await api('PUT', '/auth/password', { currentPassword, newPassword });
showAlert('pw-alert', 'Password updated successfully', 'success');
document.getElementById('pw-current').value = '';
document.getElementById('pw-new').value = '';
document.getElementById('pw-confirm').value = '';
} catch (err) {
showAlert('pw-alert', err.message);
}
}
// ── Modal helpers ──────────────────────────────────────────────────────────
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
// ── Init ───────────────────────────────────────────────────────────────────
function init() {
// Nav clicks
document.querySelectorAll('.nav-item').forEach(el => {
el.addEventListener('click', () => navigate(el.dataset.page));
});
// Login form
document.getElementById('login-form').addEventListener('submit', login);
// Logout
document.getElementById('logout-btn').addEventListener('click', logout);
// Send form
document.getElementById('send-form').addEventListener('submit', sendSMS);
document.getElementById('send-message').addEventListener('input', e => {
document.getElementById('char-count').textContent = e.target.value.length;
});
// Close modal on backdrop click
document.querySelectorAll('.modal-backdrop').forEach(m => {
m.addEventListener('click', e => { if (e.target === m) m.classList.remove('open'); });
});
checkAuth();
}
// ── Public API ─────────────────────────────────────────────────────────────
return {
init,
refreshDashboard, loadDashboard,
triggerPoll,
openWebhookModal, saveWebhook, toggleWebhook, deleteWebhook, copyWebhookUrl,
openTokenModal, createToken, copyToken, toggleApiToken, deleteToken,
saveConfig, testConnection, saveLogLevel, changePassword,
openModal, closeModal,
_paginate,
};
})();
document.addEventListener('DOMContentLoaded', App.init);