mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-08 17:53:00 +02:00
Phase 1: Backend basics - Complete Flask application with REST API
Implemented core backend functionality: - Flask application structure with blueprints - Configuration module loading from environment variables - MeshCore CLI wrapper with subprocess execution and timeout handling - Message parser for .msgs JSON Lines file format - REST API endpoints (messages, status, sync, contacts cleanup) - HTML views with Bootstrap 5 responsive design - Frontend JavaScript with auto-refresh and live updates - Custom CSS styling for chat interface API Endpoints: - GET /api/messages - List messages with pagination - POST /api/messages - Send message with optional reply-to - GET /api/status - Device connection status - POST /api/sync - Trigger message sync - POST /api/contacts/cleanup - Remove inactive contacts - GET /api/device/info - Device information Features: - Auto-refresh every 60s (configurable) - Reply to messages with @[UserName] format - Toast notifications for feedback - Settings modal for contact management - Responsive design (mobile-friendly) - Message bubbles with sender, timestamp, SNR, hop count Ready for testing on production server (192.168.131.80:5000) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/* mc-webui Custom Styles */
|
||||
|
||||
:root {
|
||||
--msg-own-bg: #e7f1ff;
|
||||
--msg-other-bg: #f8f9fa;
|
||||
--msg-border: #dee2e6;
|
||||
}
|
||||
|
||||
/* Page Layout */
|
||||
html, body {
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Messages Container */
|
||||
.messages-container {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
#messagesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Message Bubbles */
|
||||
.message {
|
||||
max-width: 70%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--msg-border);
|
||||
word-wrap: break-word;
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Own Messages (right-aligned) */
|
||||
.message.own {
|
||||
align-self: flex-end;
|
||||
background-color: var(--msg-own-bg);
|
||||
border-color: #b8daff;
|
||||
}
|
||||
|
||||
/* Other Messages (left-aligned) */
|
||||
.message.other {
|
||||
align-self: flex-start;
|
||||
background-color: var(--msg-other-bg);
|
||||
}
|
||||
|
||||
/* Message Header */
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.message-sender {
|
||||
font-weight: 600;
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.message.own .message-sender {
|
||||
color: #084298;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Message Content */
|
||||
.message-content {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Message Metadata */
|
||||
.message-meta {
|
||||
font-size: 0.7rem;
|
||||
color: #adb5bd;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Reply Button */
|
||||
.btn-reply {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Send Form */
|
||||
#messageInput {
|
||||
resize: none;
|
||||
border-radius: 0.5rem 0 0 0.5rem;
|
||||
}
|
||||
|
||||
#sendBtn {
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Status Indicators */
|
||||
.status-connected {
|
||||
color: #198754 !important;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
color: #dc3545 !important;
|
||||
}
|
||||
|
||||
.status-connecting {
|
||||
color: #ffc107 !important;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
.messages-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.message {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
#messageInput {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* mc-webui Frontend Application
|
||||
*/
|
||||
|
||||
// Global state
|
||||
let lastMessageCount = 0;
|
||||
let autoRefreshInterval = null;
|
||||
let isUserScrolling = false;
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('mc-webui initialized');
|
||||
|
||||
// Load initial messages
|
||||
loadMessages();
|
||||
|
||||
// Setup auto-refresh
|
||||
setupAutoRefresh();
|
||||
|
||||
// Setup event listeners
|
||||
setupEventListeners();
|
||||
|
||||
// Load device status
|
||||
loadStatus();
|
||||
});
|
||||
|
||||
/**
|
||||
* Setup event listeners
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
// Send message form
|
||||
const form = document.getElementById('sendMessageForm');
|
||||
const input = document.getElementById('messageInput');
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
});
|
||||
|
||||
// Handle Enter key (send) vs Shift+Enter (new line)
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// Manual refresh button
|
||||
document.getElementById('refreshBtn').addEventListener('click', function() {
|
||||
loadMessages();
|
||||
});
|
||||
|
||||
// Cleanup contacts button
|
||||
document.getElementById('cleanupBtn').addEventListener('click', function() {
|
||||
cleanupContacts();
|
||||
});
|
||||
|
||||
// Track user scrolling
|
||||
const container = document.getElementById('messagesContainer');
|
||||
container.addEventListener('scroll', function() {
|
||||
const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 100;
|
||||
isUserScrolling = !isAtBottom;
|
||||
});
|
||||
|
||||
// Load device info when settings modal opens
|
||||
const settingsModal = document.getElementById('settingsModal');
|
||||
settingsModal.addEventListener('show.bs.modal', function() {
|
||||
loadDeviceInfo();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages from API
|
||||
*/
|
||||
async function loadMessages() {
|
||||
try {
|
||||
const response = await fetch('/api/messages?limit=100');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
displayMessages(data.messages);
|
||||
updateStatus('connected');
|
||||
updateLastRefresh();
|
||||
} else {
|
||||
showNotification('Error loading messages: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading messages:', error);
|
||||
updateStatus('disconnected');
|
||||
showNotification('Failed to load messages', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display messages in the UI
|
||||
*/
|
||||
function displayMessages(messages) {
|
||||
const container = document.getElementById('messagesList');
|
||||
const wasAtBottom = !isUserScrolling;
|
||||
|
||||
// Clear loading spinner
|
||||
container.innerHTML = '';
|
||||
|
||||
if (messages.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
<p>No messages yet</p>
|
||||
<small>Send a message to get started!</small>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Render each message
|
||||
messages.forEach(msg => {
|
||||
const messageEl = createMessageElement(msg);
|
||||
container.appendChild(messageEl);
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom if user wasn't scrolling
|
||||
if (wasAtBottom) {
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
lastMessageCount = messages.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create message DOM element
|
||||
*/
|
||||
function createMessageElement(msg) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `message ${msg.is_own ? 'own' : 'other'}`;
|
||||
|
||||
const time = formatTime(msg.timestamp);
|
||||
|
||||
let metaInfo = '';
|
||||
if (msg.snr !== undefined && msg.snr !== null) {
|
||||
metaInfo += `SNR: ${msg.snr.toFixed(1)} dB`;
|
||||
}
|
||||
if (msg.path_len !== undefined && msg.path_len !== null) {
|
||||
metaInfo += ` | Hops: ${msg.path_len}`;
|
||||
}
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="message-header">
|
||||
<span class="message-sender">${escapeHtml(msg.sender)}</span>
|
||||
<span class="message-time">${time}</span>
|
||||
</div>
|
||||
<p class="message-content">${escapeHtml(msg.content)}</p>
|
||||
${metaInfo ? `<div class="message-meta">${metaInfo}</div>` : ''}
|
||||
${!msg.is_own ? `<button class="btn btn-outline-secondary btn-sm btn-reply" onclick="replyTo('${escapeHtml(msg.sender)}')">
|
||||
<i class="bi bi-reply"></i> Reply
|
||||
</button>` : ''}
|
||||
`;
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message
|
||||
*/
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById('messageInput');
|
||||
const text = input.value.trim();
|
||||
|
||||
if (!text) return;
|
||||
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
sendBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ text: text })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
input.value = '';
|
||||
showNotification('Message sent', 'success');
|
||||
|
||||
// Reload messages after short delay
|
||||
setTimeout(() => loadMessages(), 1000);
|
||||
} else {
|
||||
showNotification('Failed to send: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
showNotification('Failed to send message', 'danger');
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to a user
|
||||
*/
|
||||
function replyTo(username) {
|
||||
const input = document.getElementById('messageInput');
|
||||
input.value = `@[${username}] `;
|
||||
input.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load connection status
|
||||
*/
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/status');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
updateStatus(data.connected ? 'connected' : 'disconnected');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading status:', error);
|
||||
updateStatus('disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load device information
|
||||
*/
|
||||
async function loadDeviceInfo() {
|
||||
const infoEl = document.getElementById('deviceInfo');
|
||||
infoEl.innerHTML = '<div class="spinner-border spinner-border-sm"></div> Loading...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/device/info');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
infoEl.innerHTML = `<pre class="mb-0">${escapeHtml(data.info)}</pre>`;
|
||||
} else {
|
||||
infoEl.innerHTML = `<span class="text-danger">Error: ${escapeHtml(data.error)}</span>`;
|
||||
}
|
||||
} catch (error) {
|
||||
infoEl.innerHTML = '<span class="text-danger">Failed to load device info</span>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup inactive contacts
|
||||
*/
|
||||
async function cleanupContacts() {
|
||||
const hours = parseInt(document.getElementById('inactiveHours').value);
|
||||
|
||||
if (!confirm(`Remove all contacts inactive for more than ${hours} hours?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('cleanupBtn');
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contacts/cleanup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ hours: hours })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showNotification(data.message, 'success');
|
||||
} else {
|
||||
showNotification('Cleanup failed: ' + data.error, 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning contacts:', error);
|
||||
showNotification('Cleanup failed', 'danger');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup auto-refresh
|
||||
*/
|
||||
function setupAutoRefresh() {
|
||||
const interval = window.MC_CONFIG?.refreshInterval || 60000;
|
||||
|
||||
autoRefreshInterval = setInterval(() => {
|
||||
loadMessages();
|
||||
}, interval);
|
||||
|
||||
console.log(`Auto-refresh enabled: every ${interval / 1000}s`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection status indicator
|
||||
*/
|
||||
function updateStatus(status) {
|
||||
const statusEl = document.getElementById('statusText');
|
||||
|
||||
const icons = {
|
||||
connected: '<i class="bi bi-circle-fill status-connected"></i> Connected',
|
||||
disconnected: '<i class="bi bi-circle-fill status-disconnected"></i> Disconnected',
|
||||
connecting: '<i class="bi bi-circle-fill status-connecting"></i> Connecting...'
|
||||
};
|
||||
|
||||
statusEl.innerHTML = icons[status] || icons.connecting;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last refresh timestamp
|
||||
*/
|
||||
function updateLastRefresh() {
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleTimeString();
|
||||
document.getElementById('lastRefresh').textContent = `Last refresh: ${timeStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notification toast
|
||||
*/
|
||||
function showNotification(message, type = 'info') {
|
||||
const toastEl = document.getElementById('notificationToast');
|
||||
const toastBody = toastEl.querySelector('.toast-body');
|
||||
|
||||
toastBody.textContent = message;
|
||||
toastEl.className = `toast bg-${type} text-white`;
|
||||
|
||||
const toast = new bootstrap.Toast(toastEl);
|
||||
toast.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to bottom of messages
|
||||
*/
|
||||
function scrollToBottom() {
|
||||
const container = document.getElementById('messagesContainer');
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp
|
||||
*/
|
||||
function formatTime(timestamp) {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
// Today - show time only
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (diffDays === 1) {
|
||||
// Yesterday
|
||||
return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
// Older - show date and time
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
Reference in New Issue
Block a user