From cdc8be9eb4fe5a2b8210a72d7fca239b5b8e7fba Mon Sep 17 00:00:00 2001 From: MarekWo Date: Tue, 30 Dec 2025 08:40:22 +0100 Subject: [PATCH] refactor(contacts): Implement multi-page Contact Management with advanced sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split Contact Management into 3 dedicated pages for improved mobile usability: - /contacts/manage - Settings & navigation hub (manual approval + cleanup) - /contacts/pending - Full-screen pending contacts view - /contacts/existing - Full-screen existing contacts with search/filter/sort New Features: - Advanced sorting: Name (A-Z/Z-A) & Last advert (newest/oldest) - URL-based sort state (?sort=name&order=asc) - Activity indicators: ๐ŸŸข active, ๐ŸŸก recent, ๐Ÿ”ด inactive - Changed terminology: "Last seen" โ†’ "Last advert" (more accurate) - Cleanup tool moved from Settings modal to Contact Management page Technical Changes: - Created contacts_base.html standalone template - Split contacts.html into 3 specialized templates - Refactored contacts.js for multi-page support with page detection - Added 2 new Flask routes: /contacts/pending, /contacts/existing - Removed cleanup section from base.html Settings modal Mobile-first design: Each page has full-screen space with touch-friendly navigation and back buttons. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- README.md | 14 +- app/routes/views.py | 28 +- app/static/js/contacts.js | 372 ++++++++++++++++++++---- app/templates/base.html | 13 - app/templates/contacts-existing.html | 84 ++++++ app/templates/contacts-manage.html | 85 ++++++ app/templates/contacts-pending.html | 63 ++++ app/templates/contacts_base.html | 420 +++++++++++++++++++++++++++ 8 files changed, 1011 insertions(+), 68 deletions(-) create mode 100644 app/templates/contacts-existing.html create mode 100644 app/templates/contacts-manage.html create mode 100644 app/templates/contacts-pending.html create mode 100644 app/templates/contacts_base.html diff --git a/README.md b/README.md index a87ac91..e02fc10 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,11 @@ A lightweight web interface for meshcore-cli, providing browser-based access to - ๐Ÿ”“ **Public channels** - Join public channels (starting with #) without encryption keys - ๐ŸŽฏ **Reply to users** - Quick reply with `@[UserName]` format - ๐Ÿ‘ฅ **Contact management** - Manual contact approval mode with pending contacts list (persistent settings) -- ๐Ÿงน **Clean contacts** - Remove inactive contacts with configurable threshold + - **Dedicated pages:** Separate full-screen views for pending and existing contacts + - **Advanced sorting:** Sort contacts by name (A-Z/Z-A) or last advertisement time (newest/oldest) + - **Smart filtering:** Search by name/key, filter by contact type (CLI, REP, ROOM, SENS) + - **Activity indicators:** Visual status icons (๐ŸŸข active, ๐ŸŸก recent, ๐Ÿ”ด inactive) based on last advertisement + - **Cleanup tool:** Remove inactive contacts with configurable threshold (moved from Settings) - ๐Ÿ“ฆ **Message archiving** - Automatic daily archiving with browse-by-date selector - โšก **Efficient polling** - Lightweight update checks every 10s, UI refreshes only when needed - ๐Ÿ“ก **Network commands** - Send advertisement (advert) or flood advertisement (floodadv) for network management @@ -164,11 +168,16 @@ mc-webui/ โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ style.css # Custom styles โ”‚ โ”‚ โ””โ”€โ”€ js/ โ”‚ โ”‚ โ”œโ”€โ”€ app.js # Main page frontend logic -โ”‚ โ”‚ โ””โ”€โ”€ dm.js # Direct Messages page logic +โ”‚ โ”‚ โ”œโ”€โ”€ dm.js # Direct Messages page logic +โ”‚ โ”‚ โ””โ”€โ”€ contacts.js # Contact Management multi-page logic โ”‚ โ””โ”€โ”€ templates/ โ”‚ โ”œโ”€โ”€ base.html # Base template โ”‚ โ”œโ”€โ”€ index.html # Main chat view โ”‚ โ”œโ”€โ”€ dm.html # Direct Messages full-page view +โ”‚ โ”œโ”€โ”€ contacts_base.html # Contact pages base template +โ”‚ โ”œโ”€โ”€ contacts-manage.html # Contact Management settings & navigation +โ”‚ โ”œโ”€โ”€ contacts-pending.html # Pending contacts full-screen view +โ”‚ โ”œโ”€โ”€ contacts-existing.html # Existing contacts with sort/filter โ”‚ โ””โ”€โ”€ components/ # Reusable components โ”œโ”€โ”€ requirements.txt # Python dependencies โ”œโ”€โ”€ .env.example # Example environment config @@ -193,6 +202,7 @@ mc-webui/ - [x] Message Archiving (Daily archiving with browse-by-date selector) - [x] Smart Notifications (Unread counters per channel and total) - [x] Direct Messages (DM) - Private messaging with delivery status tracking +- [x] Advanced Contact Management - Multi-page interface with sorting, filtering, and activity tracking ### Next Steps diff --git a/app/routes/views.py b/app/routes/views.py index e72373e..1abebec 100644 --- a/app/routes/views.py +++ b/app/routes/views.py @@ -44,10 +44,34 @@ def direct_messages(): @views_bp.route('/contacts/manage') def contact_management(): """ - Contact Management view - manual approval settings and pending contacts list. + Contact Management Settings - manual approval + cleanup + navigation. """ return render_template( - 'contacts.html', + 'contacts-manage.html', + device_name=config.MC_DEVICE_NAME, + refresh_interval=config.MC_REFRESH_INTERVAL + ) + + +@views_bp.route('/contacts/pending') +def contact_pending_list(): + """ + Full-screen pending contacts list. + """ + return render_template( + 'contacts-pending.html', + device_name=config.MC_DEVICE_NAME, + refresh_interval=config.MC_REFRESH_INTERVAL + ) + + +@views_bp.route('/contacts/existing') +def contact_existing_list(): + """ + Full-screen existing contacts list with search, filter, sort. + """ + return render_template( + 'contacts-existing.html', device_name=config.MC_DEVICE_NAME, refresh_interval=config.MC_REFRESH_INTERVAL ) diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index d0fbf51..1577a77 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -1,10 +1,11 @@ /** - * Contact Management UI + * Contact Management UI - Multi-Page Version * * Features: * - Manual contact approval toggle (persistent across restarts) * - Pending contacts list with approve/copy actions - * - Existing contacts list with search, filter, and delete + * - Existing contacts list with search, filter, and sort + * - Three dedicated pages: manage, pending, existing * - Auto-refresh on page load * - Mobile-first design */ @@ -13,12 +14,17 @@ // State Management // ============================================================================= +let currentPage = null; // 'manage', 'pending', 'existing' let manualApprovalEnabled = false; let pendingContacts = []; let existingContacts = []; let filteredContacts = []; let contactToDelete = null; +// Sort state (for existing page) +let sortBy = 'last_advert'; // 'name' or 'last_advert' +let sortOrder = 'desc'; // 'asc' or 'desc' + // ============================================================================= // Initialization // ============================================================================= @@ -32,34 +38,209 @@ document.addEventListener('DOMContentLoaded', () => { return new bootstrap.Tooltip(tooltipTriggerEl); }); - // Attach event listeners - attachEventListeners(); + // Detect current page + detectCurrentPage(); - // Load initial state - loadSettings(); - loadPendingContacts(); - loadExistingContacts(); + // Initialize page-specific functionality + initializePage(); }); -function attachEventListeners() { +function detectCurrentPage() { + if (document.getElementById('managePageContent')) { + currentPage = 'manage'; + } else if (document.getElementById('pendingPageContent')) { + currentPage = 'pending'; + } else if (document.getElementById('existingPageContent')) { + currentPage = 'existing'; + } + console.log('Current page:', currentPage); +} + +function initializePage() { + switch (currentPage) { + case 'manage': + initManagePage(); + break; + case 'pending': + initPendingPage(); + break; + case 'existing': + initExistingPage(); + break; + default: + console.warn('Unknown page type'); + } +} + +// ============================================================================= +// Management Page Initialization +// ============================================================================= + +function initManagePage() { + console.log('Initializing Management page...'); + + // Load settings for manual approval toggle + loadSettings(); + + // Load contact counts for badges + loadContactCounts(); + + // Attach event listeners for manage page + attachManageEventListeners(); +} + +function attachManageEventListeners() { // Manual approval toggle const approvalSwitch = document.getElementById('manualApprovalSwitch'); if (approvalSwitch) { approvalSwitch.addEventListener('change', handleApprovalToggle); } - // Pending contacts refresh button - const refreshPendingBtn = document.getElementById('refreshPendingBtn'); - if (refreshPendingBtn) { - refreshPendingBtn.addEventListener('click', () => { + // Cleanup button + const cleanupBtn = document.getElementById('cleanupBtn'); + if (cleanupBtn) { + cleanupBtn.addEventListener('click', handleCleanupInactive); + } +} + +async function loadContactCounts() { + try { + // Fetch pending count + const pendingResp = await fetch('/api/contacts/pending'); + const pendingData = await pendingResp.json(); + + const pendingBadge = document.getElementById('pendingBadge'); + if (pendingBadge && pendingData.success) { + const count = pendingData.pending?.length || 0; + pendingBadge.textContent = count; + pendingBadge.classList.remove('spinner-border', 'spinner-border-sm'); + } + + // Fetch existing count + const existingResp = await fetch('/api/contacts/detailed'); + const existingData = await existingResp.json(); + + const existingBadge = document.getElementById('existingBadge'); + if (existingBadge && existingData.success) { + const count = existingData.count || 0; + const limit = existingData.limit || 350; + existingBadge.textContent = `${count} / ${limit}`; + existingBadge.classList.remove('spinner-border', 'spinner-border-sm'); + + // Apply counter color coding + existingBadge.classList.remove('counter-ok', 'counter-warning', 'counter-alarm'); + if (count >= 340) { + existingBadge.classList.add('counter-alarm'); + } else if (count >= 300) { + existingBadge.classList.add('counter-warning'); + } else { + existingBadge.classList.add('counter-ok'); + } + } + } catch (error) { + console.error('Error loading contact counts:', error); + } +} + +async function handleCleanupInactive() { + const hoursInput = document.getElementById('inactiveHours'); + const cleanupBtn = document.getElementById('cleanupBtn'); + + if (!hoursInput || !cleanupBtn) return; + + const hours = parseInt(hoursInput.value); + + if (isNaN(hours) || hours < 1) { + showToast('Please enter a valid number of hours', 'warning'); + return; + } + + // Confirm action + if (!confirm(`This will remove all contacts inactive for more than ${hours} hours. Continue?`)) { + return; + } + + // Disable button during operation + const originalHTML = cleanupBtn.innerHTML; + cleanupBtn.disabled = true; + cleanupBtn.innerHTML = ' Cleaning...'; + + try { + const response = await fetch('/api/contacts/cleanup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + inactive_hours: hours + }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(data.message || 'Cleanup completed successfully', 'success'); + // Reload contact counts + loadContactCounts(); + } else { + showToast('Cleanup failed: ' + (data.error || 'Unknown error'), 'danger'); + } + } catch (error) { + console.error('Error during cleanup:', error); + showToast('Network error during cleanup', 'danger'); + } finally { + // Re-enable button + cleanupBtn.disabled = false; + cleanupBtn.innerHTML = originalHTML; + } +} + +// ============================================================================= +// Pending Page Initialization +// ============================================================================= + +function initPendingPage() { + console.log('Initializing Pending page...'); + + // Load pending contacts + loadPendingContacts(); + + // Attach event listeners for pending page + attachPendingEventListeners(); +} + +function attachPendingEventListeners() { + // Refresh button + const refreshBtn = document.getElementById('refreshPendingBtn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', () => { loadPendingContacts(); }); } +} - // Existing contacts refresh button - const refreshExistingBtn = document.getElementById('refreshExistingBtn'); - if (refreshExistingBtn) { - refreshExistingBtn.addEventListener('click', () => { +// ============================================================================= +// Existing Page Initialization +// ============================================================================= + +function initExistingPage() { + console.log('Initializing Existing page...'); + + // Parse sort parameters from URL + parseSortParamsFromURL(); + + // Load existing contacts + loadExistingContacts(); + + // Attach event listeners for existing page + attachExistingEventListeners(); +} + +function attachExistingEventListeners() { + // Refresh button + const refreshBtn = document.getElementById('refreshExistingBtn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', () => { loadExistingContacts(); }); } @@ -68,7 +249,7 @@ function attachEventListeners() { const searchInput = document.getElementById('searchInput'); if (searchInput) { searchInput.addEventListener('input', () => { - applyFilters(); + applySortAndFilters(); }); } @@ -76,7 +257,22 @@ function attachEventListeners() { const typeFilter = document.getElementById('typeFilter'); if (typeFilter) { typeFilter.addEventListener('change', () => { - applyFilters(); + applySortAndFilters(); + }); + } + + // Sort buttons + const sortByName = document.getElementById('sortByName'); + if (sortByName) { + sortByName.addEventListener('click', () => { + handleSortChange('name'); + }); + } + + const sortByLastAdvert = document.getElementById('sortByLastAdvert'); + if (sortByLastAdvert) { + sortByLastAdvert.addEventListener('click', () => { + handleSortChange('last_advert'); }); } @@ -90,7 +286,7 @@ function attachEventListeners() { } // ============================================================================= -// Settings Management +// Settings Management (shared) // ============================================================================= async function loadSettings() { @@ -134,9 +330,6 @@ async function handleApprovalToggle(event) { enabled ? 'Manual approval enabled' : 'Manual approval disabled', 'success' ); - - // Reload pending contacts after toggle - setTimeout(() => loadPendingContacts(), 500); } else { console.error('Failed to update setting:', data.error); showToast('Failed to update setting: ' + data.error, 'danger'); @@ -156,7 +349,6 @@ async function handleApprovalToggle(event) { function updateApprovalUI(enabled) { const switchEl = document.getElementById('manualApprovalSwitch'); const labelEl = document.getElementById('switchLabel'); - const infoEl = document.getElementById('approvalInfo'); if (switchEl) { switchEl.checked = enabled; @@ -167,10 +359,6 @@ function updateApprovalUI(enabled) { ? 'Manual approval enabled' : 'Automatic approval (default)'; } - - if (infoEl) { - infoEl.style.display = enabled ? 'none' : 'inline-block'; - } } // ============================================================================= @@ -182,7 +370,7 @@ async function loadPendingContacts() { const emptyEl = document.getElementById('pendingEmpty'); const listEl = document.getElementById('pendingList'); const errorEl = document.getElementById('pendingError'); - const countBadge = document.getElementById('pendingCount'); + const countBadge = document.getElementById('pendingCountBadge'); // Show loading state if (loadingEl) loadingEl.style.display = 'block'; @@ -207,7 +395,7 @@ async function loadPendingContacts() { // Render pending contacts list renderPendingList(pendingContacts); - // Update count badge + // Update count badge (in navbar) if (countBadge) { countBadge.textContent = pendingContacts.length; countBadge.style.display = 'inline-block'; @@ -216,7 +404,7 @@ async function loadPendingContacts() { } else { console.error('Failed to load pending contacts:', data.error); if (errorEl) { - const errorMsg = document.getElementById('errorMessage'); + const errorMsg = document.getElementById('pendingErrorMessage'); if (errorMsg) errorMsg.textContent = data.error || 'Failed to load pending contacts'; errorEl.style.display = 'block'; } @@ -225,7 +413,7 @@ async function loadPendingContacts() { console.error('Error loading pending contacts:', error); if (loadingEl) loadingEl.style.display = 'none'; if (errorEl) { - const errorMsg = document.getElementById('errorMessage'); + const errorMsg = document.getElementById('pendingErrorMessage'); if (errorMsg) errorMsg.textContent = 'Network error: ' + error.message; errorEl.style.display = 'block'; } @@ -425,15 +613,15 @@ async function loadExistingContacts() { existingContacts = data.contacts || []; filteredContacts = [...existingContacts]; - // Update counter badge + // Update counter badge (in navbar) updateCounter(data.count, data.limit); if (existingContacts.length === 0) { // Show empty state if (emptyEl) emptyEl.style.display = 'block'; } else { - // Apply filters and render - applyFilters(); + // Apply filters and sort + applySortAndFilters(); } } else { console.error('Failed to load existing contacts:', data.error); @@ -474,14 +662,83 @@ function updateCounter(count, limit) { } } -function applyFilters() { +// ============================================================================= +// Sorting Functionality (Existing Page) +// ============================================================================= + +function parseSortParamsFromURL() { + const urlParams = new URLSearchParams(window.location.search); + sortBy = urlParams.get('sort') || 'last_advert'; + sortOrder = urlParams.get('order') || 'desc'; + + console.log('Parsed sort params:', { sortBy, sortOrder }); + + // Update UI to reflect current sort + updateSortUI(); +} + +function handleSortChange(newSortBy) { + if (sortBy === newSortBy) { + // Toggle order + sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; + } else { + // Change sort field + sortBy = newSortBy; + // Set default order for new field + sortOrder = newSortBy === 'name' ? 'asc' : 'desc'; + } + + console.log('Sort changed to:', { sortBy, sortOrder }); + + // Update URL parameters + updateURLWithSortParams(); + + // Update UI + updateSortUI(); + + // Re-apply filters and sort + applySortAndFilters(); +} + +function updateURLWithSortParams() { + const url = new URL(window.location); + url.searchParams.set('sort', sortBy); + url.searchParams.set('order', sortOrder); + window.history.replaceState({}, '', url); +} + +function updateSortUI() { + // Update sort button active states and icons + const sortButtons = document.querySelectorAll('.sort-btn'); + + sortButtons.forEach(btn => { + const btnSort = btn.dataset.sort; + const icon = btn.querySelector('i'); + + if (btnSort === sortBy) { + // Active button + btn.classList.add('active'); + if (icon) { + icon.className = sortOrder === 'asc' ? 'bi bi-sort-up' : 'bi bi-sort-down'; + } + } else { + // Inactive button + btn.classList.remove('active'); + if (icon) { + icon.className = 'bi bi-sort-down'; // Default icon + } + } + }); +} + +function applySortAndFilters() { const searchInput = document.getElementById('searchInput'); const typeFilter = document.getElementById('typeFilter'); const searchTerm = searchInput ? searchInput.value.toLowerCase() : ''; const selectedType = typeFilter ? typeFilter.value : 'ALL'; - // Filter contacts + // First, filter contacts filteredContacts = existingContacts.filter(contact => { // Type filter if (selectedType !== 'ALL' && contact.type_label !== selectedType) { @@ -498,7 +755,20 @@ function applyFilters() { return true; }); - // Render filtered contacts + // Then, sort filtered contacts + filteredContacts.sort((a, b) => { + if (sortBy === 'name') { + const comparison = a.name.localeCompare(b.name); + return sortOrder === 'asc' ? comparison : -comparison; + } else if (sortBy === 'last_advert') { + const aTime = a.last_seen || 0; + const bTime = b.last_seen || 0; + return sortOrder === 'desc' ? bTime - aTime : aTime - bTime; + } + return 0; + }); + + // Render sorted and filtered contacts renderExistingList(filteredContacts); } @@ -569,7 +839,7 @@ function formatRelativeTime(timestamp) { } /** - * Get activity status indicator based on last_seen timestamp + * Get activity status indicator based on last_advert timestamp * Returns: { icon: string, color: string, title: string } */ function getActivityStatus(timestamp) { @@ -589,7 +859,7 @@ function getActivityStatus(timestamp) { return { icon: '๐ŸŸข', color: '#28a745', - title: 'Active (seen recently)' + title: 'Active (advert received recently)' }; } @@ -654,10 +924,10 @@ function createExistingContactCard(contact, index) { keyDiv.textContent = contact.public_key_prefix; keyDiv.title = 'Public Key Prefix'; - // Last seen row (with activity status indicator) - const lastSeenDiv = document.createElement('div'); - lastSeenDiv.className = 'text-muted small d-flex align-items-center gap-1'; - lastSeenDiv.style.marginBottom = '0.25rem'; + // Last advert row (with activity status indicator) + const lastAdvertDiv = document.createElement('div'); + lastAdvertDiv.className = 'text-muted small d-flex align-items-center gap-1'; + lastAdvertDiv.style.marginBottom = '0.25rem'; if (contact.last_seen) { const status = getActivityStatus(contact.last_seen); @@ -669,10 +939,10 @@ function createExistingContactCard(contact, index) { statusIcon.title = status.title; const timeText = document.createElement('span'); - timeText.textContent = `Last seen: ${relativeTime}`; + timeText.textContent = `Last advert: ${relativeTime}`; - lastSeenDiv.appendChild(statusIcon); - lastSeenDiv.appendChild(timeText); + lastAdvertDiv.appendChild(statusIcon); + lastAdvertDiv.appendChild(timeText); } else { // No last_seen data available const statusIcon = document.createElement('span'); @@ -680,10 +950,10 @@ function createExistingContactCard(contact, index) { statusIcon.style.fontSize = '0.9rem'; const timeText = document.createElement('span'); - timeText.textContent = 'Last seen: Unknown'; + timeText.textContent = 'Last advert: Unknown'; - lastSeenDiv.appendChild(statusIcon); - lastSeenDiv.appendChild(timeText); + lastAdvertDiv.appendChild(statusIcon); + lastAdvertDiv.appendChild(timeText); } // Path/mode (optional) @@ -716,7 +986,7 @@ function createExistingContactCard(contact, index) { // Assemble card card.appendChild(infoRow); card.appendChild(keyDiv); - card.appendChild(lastSeenDiv); + card.appendChild(lastAdvertDiv); if (pathDiv) card.appendChild(pathDiv); card.appendChild(actionsDiv); diff --git a/app/templates/base.html b/app/templates/base.html index a2af338..cf328c4 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -244,19 +244,6 @@