mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-06-29 14:30:57 +02:00
de0108d6aa
Contacts cache accumulates all known node names from device contacts and adverts into a JSONL file, so @mentions work even after contacts are removed from the device. Background thread scans adverts every 45s and parses advert payloads to extract public keys and node names. Existing Contacts page now shows merged view with "Cache" badge for contacts not on device, plus source filter (All/On device/Cache only). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2221 lines
74 KiB
JavaScript
2221 lines
74 KiB
JavaScript
/**
|
|
* 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 sort
|
|
* - Three dedicated pages: manage, pending, existing
|
|
* - Auto-refresh on page load
|
|
* - Mobile-first design
|
|
*/
|
|
|
|
// =============================================================================
|
|
// Global Navigation Helper
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Global navigation function - cleans up DOM before navigation
|
|
* This prevents viewport issues when navigating between pages
|
|
*/
|
|
window.navigateTo = function(url) {
|
|
// Remove any lingering Bootstrap classes/backdrops
|
|
document.body.classList.remove('modal-open', 'offcanvas-open');
|
|
document.body.style.overflow = '';
|
|
document.body.style.paddingRight = '';
|
|
|
|
// Remove any backdrops
|
|
const backdrops = document.querySelectorAll('.offcanvas-backdrop, .modal-backdrop');
|
|
backdrops.forEach(backdrop => backdrop.remove());
|
|
|
|
// Navigate after cleanup
|
|
setTimeout(() => {
|
|
window.location.href = url;
|
|
}, 100);
|
|
};
|
|
|
|
// =============================================================================
|
|
// State Management
|
|
// =============================================================================
|
|
|
|
let currentPage = null; // 'manage', 'pending', 'existing'
|
|
let manualApprovalEnabled = false;
|
|
let pendingContacts = [];
|
|
let filteredPendingContacts = []; // Filtered pending contacts (for pending page filtering)
|
|
let existingContacts = [];
|
|
let filteredContacts = [];
|
|
let contactToDelete = null;
|
|
let protectedContacts = []; // List of protected public_keys
|
|
|
|
// Sort state (for existing page)
|
|
let sortBy = 'last_advert'; // 'name' or 'last_advert'
|
|
let sortOrder = 'desc'; // 'asc' or 'desc'
|
|
|
|
// Auto-cleanup state
|
|
let autoCleanupSettings = null;
|
|
let cleanupSaveDebounceTimer = null;
|
|
let cleanupTimezone = 'local'; // Timezone from server (e.g., 'Europe/Warsaw')
|
|
|
|
// Map state (Leaflet)
|
|
let leafletMap = null;
|
|
let markersGroup = null;
|
|
|
|
// =============================================================================
|
|
// Leaflet Map Functions
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Initialize Leaflet map (called once on first modal open)
|
|
*/
|
|
function initLeafletMap() {
|
|
if (leafletMap) return;
|
|
|
|
leafletMap = L.map('leafletMap').setView([52.0, 19.0], 6);
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
}).addTo(leafletMap);
|
|
|
|
markersGroup = L.layerGroup().addTo(leafletMap);
|
|
}
|
|
|
|
/**
|
|
* Show single contact on map
|
|
*/
|
|
function showContactOnMap(name, lat, lon) {
|
|
const modalEl = document.getElementById('mapModal');
|
|
const modal = new bootstrap.Modal(modalEl);
|
|
document.getElementById('mapModalTitle').textContent = name;
|
|
|
|
// Hide type filter panel for single contact view
|
|
const filterPanel = document.getElementById('mapTypeFilter');
|
|
if (filterPanel) filterPanel.classList.add('d-none');
|
|
|
|
const onShown = function() {
|
|
initLeafletMap();
|
|
markersGroup.clearLayers();
|
|
|
|
L.marker([lat, lon])
|
|
.addTo(markersGroup)
|
|
.bindPopup(`<b>${name}</b>`)
|
|
.openPopup();
|
|
|
|
leafletMap.setView([lat, lon], 13);
|
|
leafletMap.invalidateSize();
|
|
|
|
modalEl.removeEventListener('shown.bs.modal', onShown);
|
|
};
|
|
|
|
modalEl.addEventListener('shown.bs.modal', onShown);
|
|
modal.show();
|
|
}
|
|
|
|
// Make showContactOnMap available globally
|
|
window.showContactOnMap = showContactOnMap;
|
|
|
|
// =============================================================================
|
|
// Initialization
|
|
// =============================================================================
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
console.log('Contact Management UI initialized');
|
|
|
|
// Initialize Bootstrap tooltips
|
|
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
|
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
|
return new bootstrap.Tooltip(tooltipTriggerEl);
|
|
});
|
|
|
|
// Detect current page
|
|
detectCurrentPage();
|
|
|
|
// Initialize page-specific functionality
|
|
initializePage();
|
|
});
|
|
|
|
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();
|
|
|
|
// Load cleanup settings (populates form and auto-cleanup status)
|
|
loadCleanupSettings();
|
|
|
|
// Attach event listeners for manage page
|
|
attachManageEventListeners();
|
|
}
|
|
|
|
function attachManageEventListeners() {
|
|
// Manual approval toggle
|
|
const approvalSwitch = document.getElementById('manualApprovalSwitch');
|
|
if (approvalSwitch) {
|
|
approvalSwitch.addEventListener('change', handleApprovalToggle);
|
|
}
|
|
|
|
// Cleanup preview button
|
|
const cleanupPreviewBtn = document.getElementById('cleanupPreviewBtn');
|
|
if (cleanupPreviewBtn) {
|
|
cleanupPreviewBtn.addEventListener('click', handleCleanupPreview);
|
|
}
|
|
|
|
// Cleanup confirm button (in modal)
|
|
const confirmCleanupBtn = document.getElementById('confirmCleanupBtn');
|
|
if (confirmCleanupBtn) {
|
|
confirmCleanupBtn.addEventListener('click', handleCleanupConfirm);
|
|
}
|
|
|
|
// Auto-cleanup toggle
|
|
const autoCleanupSwitch = document.getElementById('autoCleanupSwitch');
|
|
if (autoCleanupSwitch) {
|
|
autoCleanupSwitch.addEventListener('change', handleAutoCleanupToggle);
|
|
}
|
|
|
|
// Debounced auto-save for cleanup filter inputs
|
|
const cleanupNameFilter = document.getElementById('cleanupNameFilter');
|
|
if (cleanupNameFilter) {
|
|
cleanupNameFilter.addEventListener('input', debouncedSaveCleanupCriteria);
|
|
}
|
|
|
|
const cleanupDays = document.getElementById('cleanupDays');
|
|
if (cleanupDays) {
|
|
cleanupDays.addEventListener('input', debouncedSaveCleanupCriteria);
|
|
}
|
|
|
|
// Type filter checkboxes
|
|
document.querySelectorAll('.cleanup-type-filter').forEach(cb => {
|
|
cb.addEventListener('change', debouncedSaveCleanupCriteria);
|
|
});
|
|
|
|
// Date field radio buttons
|
|
document.querySelectorAll('input[name="cleanupDateField"]').forEach(radio => {
|
|
radio.addEventListener('change', debouncedSaveCleanupCriteria);
|
|
});
|
|
|
|
// Cleanup hour selector (only saves when auto-cleanup is enabled)
|
|
const cleanupHour = document.getElementById('cleanupHour');
|
|
if (cleanupHour) {
|
|
cleanupHour.addEventListener('change', () => {
|
|
// Only save if auto-cleanup is enabled
|
|
if (autoCleanupSettings && autoCleanupSettings.enabled) {
|
|
saveCleanupSettings(true);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async function loadContactCounts() {
|
|
try {
|
|
// Get saved type filter from localStorage
|
|
const savedTypes = loadPendingTypeFilter();
|
|
|
|
// Build query string with types parameter
|
|
const params = new URLSearchParams();
|
|
savedTypes.forEach(type => params.append('types', type));
|
|
|
|
// Fetch pending count (with type filter)
|
|
const pendingResp = await fetch(`/api/contacts/pending?${params.toString()}`);
|
|
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);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Auto-Cleanup Settings Management
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Load cleanup settings from server and apply to UI.
|
|
*/
|
|
async function loadCleanupSettings() {
|
|
const statusText = document.getElementById('autoCleanupStatusText');
|
|
if (statusText) statusText.textContent = 'Loading...';
|
|
|
|
try {
|
|
const response = await fetch('/api/contacts/cleanup-settings');
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
autoCleanupSettings = data.settings;
|
|
cleanupTimezone = data.timezone || 'local';
|
|
applyCleanupSettingsToUI(autoCleanupSettings);
|
|
console.log('Loaded cleanup settings:', autoCleanupSettings, 'timezone:', cleanupTimezone);
|
|
} else {
|
|
console.error('Failed to load cleanup settings:', data.error);
|
|
if (statusText) statusText.textContent = 'Error loading settings';
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading cleanup settings:', error);
|
|
if (statusText) statusText.textContent = 'Network error';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply cleanup settings to form inputs.
|
|
* @param {Object} settings - Cleanup settings object
|
|
*/
|
|
function applyCleanupSettingsToUI(settings) {
|
|
// Name filter
|
|
const nameInput = document.getElementById('cleanupNameFilter');
|
|
if (nameInput) {
|
|
nameInput.value = settings.name_filter || '';
|
|
}
|
|
|
|
// Days
|
|
const daysInput = document.getElementById('cleanupDays');
|
|
if (daysInput) {
|
|
daysInput.value = settings.days || 0;
|
|
}
|
|
|
|
// Date field
|
|
const dateFieldValue = settings.date_field || 'last_advert';
|
|
const dateRadio = document.querySelector(`input[name="cleanupDateField"][value="${dateFieldValue}"]`);
|
|
if (dateRadio) {
|
|
dateRadio.checked = true;
|
|
}
|
|
|
|
// Contact types
|
|
const types = settings.types || [1, 2, 3, 4];
|
|
document.querySelectorAll('.cleanup-type-filter').forEach(cb => {
|
|
cb.checked = types.includes(parseInt(cb.value));
|
|
});
|
|
|
|
// Auto-cleanup switch and status
|
|
const autoCleanupSwitch = document.getElementById('autoCleanupSwitch');
|
|
const statusText = document.getElementById('autoCleanupStatusText');
|
|
const hourSelect = document.getElementById('cleanupHour');
|
|
const timezoneLabel = document.getElementById('cleanupTimezoneLabel');
|
|
|
|
if (autoCleanupSwitch) {
|
|
autoCleanupSwitch.checked = settings.enabled || false;
|
|
}
|
|
|
|
// Hour selector
|
|
const hour = settings.hour !== undefined ? settings.hour : 1;
|
|
if (hourSelect) {
|
|
hourSelect.value = hour;
|
|
hourSelect.disabled = !settings.enabled;
|
|
}
|
|
|
|
// Display timezone next to hour selector
|
|
if (timezoneLabel) {
|
|
timezoneLabel.textContent = `(${cleanupTimezone})`;
|
|
}
|
|
|
|
if (statusText) {
|
|
if (settings.enabled) {
|
|
const hourStr = hour.toString().padStart(2, '0');
|
|
statusText.textContent = `Enabled (runs daily at ${hourStr}:00 ${cleanupTimezone})`;
|
|
statusText.classList.remove('text-muted');
|
|
statusText.classList.add('text-success');
|
|
} else {
|
|
statusText.textContent = 'Disabled';
|
|
statusText.classList.remove('text-success');
|
|
statusText.classList.add('text-muted');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle auto-cleanup toggle change.
|
|
* Validates criteria before enabling.
|
|
*/
|
|
async function handleAutoCleanupToggle(event) {
|
|
const enabled = event.target.checked;
|
|
const statusText = document.getElementById('autoCleanupStatusText');
|
|
const hourSelect = document.getElementById('cleanupHour');
|
|
|
|
// Validate before enabling
|
|
if (enabled) {
|
|
const criteria = collectCleanupCriteria();
|
|
|
|
// Check if days > 0
|
|
if (criteria.days <= 0) {
|
|
showToast('Set "Days of Inactivity" > 0 before enabling auto-cleanup', 'warning');
|
|
event.target.checked = false;
|
|
return;
|
|
}
|
|
|
|
// Check if at least one type is selected
|
|
if (criteria.types.length === 0) {
|
|
showToast('Select at least one contact type before enabling auto-cleanup', 'warning');
|
|
event.target.checked = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Enable/disable hour selector
|
|
if (hourSelect) {
|
|
hourSelect.disabled = !enabled;
|
|
}
|
|
|
|
// Update status text while saving
|
|
if (statusText) {
|
|
statusText.textContent = 'Saving...';
|
|
statusText.classList.remove('text-success', 'text-muted');
|
|
}
|
|
|
|
// Save settings with new enabled state
|
|
const success = await saveCleanupSettings(enabled);
|
|
|
|
if (!success) {
|
|
// Revert switch and hour selector on failure
|
|
event.target.checked = !enabled;
|
|
if (hourSelect) {
|
|
hourSelect.disabled = enabled;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Debounced save for cleanup criteria changes.
|
|
* Only saves criteria, does not change enabled state.
|
|
*/
|
|
function debouncedSaveCleanupCriteria() {
|
|
// Clear existing timer
|
|
if (cleanupSaveDebounceTimer) {
|
|
clearTimeout(cleanupSaveDebounceTimer);
|
|
}
|
|
|
|
// Set new timer (500ms debounce)
|
|
cleanupSaveDebounceTimer = setTimeout(() => {
|
|
// Only save if auto-cleanup settings have been loaded
|
|
if (autoCleanupSettings !== null) {
|
|
// Preserve current enabled state
|
|
saveCleanupSettings(autoCleanupSettings.enabled);
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
/**
|
|
* Save cleanup settings to server.
|
|
* @param {boolean} enabled - Whether auto-cleanup should be enabled
|
|
* @returns {Promise<boolean>} True if save was successful
|
|
*/
|
|
async function saveCleanupSettings(enabled) {
|
|
const criteria = collectCleanupCriteria();
|
|
const statusText = document.getElementById('autoCleanupStatusText');
|
|
const hourSelect = document.getElementById('cleanupHour');
|
|
const hour = hourSelect ? parseInt(hourSelect.value) : 1;
|
|
|
|
const settings = {
|
|
enabled: enabled,
|
|
types: criteria.types,
|
|
date_field: criteria.date_field,
|
|
days: criteria.days,
|
|
name_filter: criteria.name_filter,
|
|
hour: hour
|
|
};
|
|
|
|
try {
|
|
const response = await fetch('/api/contacts/cleanup-settings', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(settings)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
autoCleanupSettings = data.settings;
|
|
// Update timezone if provided in response
|
|
if (data.timezone) {
|
|
cleanupTimezone = data.timezone;
|
|
}
|
|
|
|
// Update status text
|
|
if (statusText) {
|
|
if (data.settings.enabled) {
|
|
const savedHour = data.settings.hour !== undefined ? data.settings.hour : 1;
|
|
const hourStr = savedHour.toString().padStart(2, '0');
|
|
statusText.textContent = `Enabled (runs daily at ${hourStr}:00 ${cleanupTimezone})`;
|
|
statusText.classList.remove('text-muted');
|
|
statusText.classList.add('text-success');
|
|
} else {
|
|
statusText.textContent = 'Disabled';
|
|
statusText.classList.remove('text-success');
|
|
statusText.classList.add('text-muted');
|
|
}
|
|
}
|
|
|
|
console.log('Cleanup settings saved:', data.settings);
|
|
return true;
|
|
} else {
|
|
console.error('Failed to save cleanup settings:', data.error);
|
|
showToast('Failed to save settings: ' + data.error, 'danger');
|
|
|
|
// Restore previous status
|
|
if (statusText && autoCleanupSettings) {
|
|
if (autoCleanupSettings.enabled) {
|
|
const prevHour = autoCleanupSettings.hour !== undefined ? autoCleanupSettings.hour : 1;
|
|
const hourStr = prevHour.toString().padStart(2, '0');
|
|
statusText.textContent = `Enabled (runs daily at ${hourStr}:00 ${cleanupTimezone})`;
|
|
} else {
|
|
statusText.textContent = 'Disabled';
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving cleanup settings:', error);
|
|
showToast('Network error saving settings', 'danger');
|
|
|
|
if (statusText) {
|
|
statusText.textContent = 'Save failed';
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Global variable to store preview contacts
|
|
let cleanupPreviewContacts = [];
|
|
|
|
function collectCleanupCriteria() {
|
|
/**
|
|
* Collect cleanup filter criteria from form inputs.
|
|
*
|
|
* Returns:
|
|
* Object with criteria: {name_filter, types, date_field, days}
|
|
*/
|
|
// Name filter
|
|
const nameFilter = document.getElementById('cleanupNameFilter')?.value?.trim() || '';
|
|
|
|
// Selected types (checked checkboxes)
|
|
const typeCheckboxes = document.querySelectorAll('.cleanup-type-filter:checked');
|
|
const types = Array.from(typeCheckboxes).map(cb => parseInt(cb.value));
|
|
|
|
// Date field (radio button)
|
|
const dateFieldRadio = document.querySelector('input[name="cleanupDateField"]:checked');
|
|
const dateField = dateFieldRadio?.value || 'last_advert';
|
|
|
|
// Days of inactivity
|
|
const days = parseInt(document.getElementById('cleanupDays')?.value) || 0;
|
|
|
|
return {
|
|
name_filter: nameFilter,
|
|
types: types,
|
|
date_field: dateField,
|
|
days: days
|
|
};
|
|
}
|
|
|
|
async function handleCleanupPreview() {
|
|
const previewBtn = document.getElementById('cleanupPreviewBtn');
|
|
if (!previewBtn) return;
|
|
|
|
// Collect filter criteria
|
|
const criteria = collectCleanupCriteria();
|
|
|
|
// Validate: at least one type must be selected
|
|
if (criteria.types.length === 0) {
|
|
showToast('Please select at least one contact type', 'warning');
|
|
return;
|
|
}
|
|
|
|
// Disable button during preview
|
|
const originalHTML = previewBtn.innerHTML;
|
|
previewBtn.disabled = true;
|
|
previewBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> Loading...';
|
|
|
|
try {
|
|
const response = await fetch('/api/contacts/preview-cleanup', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(criteria)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
cleanupPreviewContacts = data.contacts || [];
|
|
|
|
if (cleanupPreviewContacts.length === 0) {
|
|
showToast('No contacts match the selected criteria', 'info');
|
|
return;
|
|
}
|
|
|
|
// Populate modal with preview
|
|
populateCleanupModal(cleanupPreviewContacts);
|
|
|
|
// Show modal
|
|
const modal = new bootstrap.Modal(document.getElementById('cleanupConfirmModal'));
|
|
modal.show();
|
|
} else {
|
|
showToast('Preview failed: ' + (data.error || 'Unknown error'), 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error during cleanup preview:', error);
|
|
showToast('Network error during preview', 'danger');
|
|
} finally {
|
|
// Re-enable button
|
|
previewBtn.disabled = false;
|
|
previewBtn.innerHTML = originalHTML;
|
|
}
|
|
}
|
|
|
|
function populateCleanupModal(contacts) {
|
|
/**
|
|
* Populate cleanup confirmation modal with list of contacts.
|
|
*/
|
|
const countEl = document.getElementById('cleanupContactCount');
|
|
const listEl = document.getElementById('cleanupContactList');
|
|
|
|
if (countEl) {
|
|
countEl.textContent = contacts.length;
|
|
}
|
|
|
|
if (listEl) {
|
|
listEl.innerHTML = '';
|
|
|
|
contacts.forEach(contact => {
|
|
const item = document.createElement('div');
|
|
item.className = 'list-group-item';
|
|
|
|
// Format last seen time
|
|
const lastSeenTimestamp = contact.last_advert || 0;
|
|
const lastSeenText = lastSeenTimestamp > 0 ? formatRelativeTime(lastSeenTimestamp) : 'Never';
|
|
|
|
item.innerHTML = `
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<strong>${escapeHtml(contact.name)}</strong>
|
|
<br>
|
|
<small class="text-muted">
|
|
Type: <span class="badge bg-secondary">${contact.type_label}</span>
|
|
| Last advert: ${lastSeenText}
|
|
</small>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
listEl.appendChild(item);
|
|
});
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
/**
|
|
* Escape HTML special characters to prevent XSS.
|
|
*/
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
async function handleCleanupConfirm() {
|
|
const confirmBtn = document.getElementById('confirmCleanupBtn');
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('cleanupConfirmModal'));
|
|
|
|
if (!confirmBtn) return;
|
|
|
|
// Collect criteria again (in case user changed filters)
|
|
const criteria = collectCleanupCriteria();
|
|
|
|
// Disable button during cleanup
|
|
const originalHTML = confirmBtn.innerHTML;
|
|
confirmBtn.disabled = true;
|
|
confirmBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting...';
|
|
|
|
try {
|
|
const response = await fetch('/api/contacts/cleanup', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(criteria)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
// Hide modal first
|
|
if (modal) modal.hide();
|
|
|
|
// Show success message
|
|
let message = `Cleanup completed: ${data.deleted_count} deleted`;
|
|
if (data.failed_count > 0) {
|
|
message += `, ${data.failed_count} failed`;
|
|
}
|
|
|
|
showToast(message, data.failed_count > 0 ? 'warning' : 'success');
|
|
|
|
// Show failures if any
|
|
if (data.failures && data.failures.length > 0) {
|
|
console.error('Cleanup failures:', data.failures);
|
|
// Optionally show detailed failure list to user
|
|
}
|
|
|
|
// Reload contact counts
|
|
loadContactCounts();
|
|
|
|
// Clear preview
|
|
cleanupPreviewContacts = [];
|
|
} 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
|
|
confirmBtn.disabled = false;
|
|
confirmBtn.innerHTML = originalHTML;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Pending Page Initialization
|
|
// =============================================================================
|
|
|
|
function initPendingPage() {
|
|
console.log('Initializing Pending page...');
|
|
|
|
// Load saved type filter and set badges
|
|
const savedTypes = loadPendingTypeFilter();
|
|
setTypeFilterBadges(savedTypes);
|
|
|
|
// Load pending contacts (will use filter from badges)
|
|
loadPendingContacts();
|
|
|
|
// Attach event listeners for pending page
|
|
attachPendingEventListeners();
|
|
}
|
|
|
|
function attachPendingEventListeners() {
|
|
// Refresh button
|
|
const refreshBtn = document.getElementById('refreshPendingBtn');
|
|
if (refreshBtn) {
|
|
refreshBtn.addEventListener('click', () => {
|
|
loadPendingContacts();
|
|
});
|
|
}
|
|
|
|
// Search input - filter on typing
|
|
const searchInput = document.getElementById('pendingSearchInput');
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', () => {
|
|
applyPendingFilters();
|
|
});
|
|
}
|
|
|
|
// Type filter badges - toggle on click, save to localStorage and reload
|
|
['typeFilterCLI', 'typeFilterREP', 'typeFilterROOM', 'typeFilterSENS'].forEach(id => {
|
|
const badge = document.getElementById(id);
|
|
if (badge) {
|
|
badge.addEventListener('click', () => {
|
|
// Toggle active state
|
|
badge.classList.toggle('active');
|
|
|
|
// Save selected types to localStorage
|
|
const selectedTypes = getSelectedTypes();
|
|
savePendingTypeFilter(selectedTypes);
|
|
|
|
// Reload contacts from API with new filter
|
|
loadPendingContacts();
|
|
});
|
|
}
|
|
});
|
|
|
|
// Add Filtered button - show batch approval modal
|
|
const addFilteredBtn = document.getElementById('addFilteredBtn');
|
|
if (addFilteredBtn) {
|
|
addFilteredBtn.addEventListener('click', () => {
|
|
showBatchApprovalModal();
|
|
});
|
|
}
|
|
|
|
// Confirm Batch Approval button - approve all filtered contacts
|
|
const confirmBatchBtn = document.getElementById('confirmBatchApprovalBtn');
|
|
if (confirmBatchBtn) {
|
|
confirmBatchBtn.addEventListener('click', () => {
|
|
batchApproveContacts();
|
|
});
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Existing Page Initialization
|
|
// =============================================================================
|
|
|
|
function initExistingPage() {
|
|
console.log('Initializing Existing page...');
|
|
|
|
// Parse sort parameters from URL
|
|
parseSortParamsFromURL();
|
|
|
|
// Load protected contacts first, then load existing contacts
|
|
loadProtectedContacts().then(() => {
|
|
loadExistingContacts();
|
|
});
|
|
|
|
// Attach event listeners for existing page
|
|
attachExistingEventListeners();
|
|
}
|
|
|
|
function attachExistingEventListeners() {
|
|
// Refresh button
|
|
const refreshBtn = document.getElementById('refreshExistingBtn');
|
|
if (refreshBtn) {
|
|
refreshBtn.addEventListener('click', () => {
|
|
loadExistingContacts();
|
|
});
|
|
}
|
|
|
|
// Search input
|
|
const searchInput = document.getElementById('searchInput');
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', () => {
|
|
applySortAndFilters();
|
|
});
|
|
}
|
|
|
|
// Source filter (device / cache only)
|
|
const sourceFilter = document.getElementById('sourceFilter');
|
|
if (sourceFilter) {
|
|
sourceFilter.addEventListener('change', () => {
|
|
applySortAndFilters();
|
|
});
|
|
}
|
|
|
|
// Type filter
|
|
const typeFilter = document.getElementById('typeFilter');
|
|
if (typeFilter) {
|
|
typeFilter.addEventListener('change', () => {
|
|
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');
|
|
});
|
|
}
|
|
|
|
// Delete confirmation button
|
|
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
|
|
if (confirmDeleteBtn) {
|
|
confirmDeleteBtn.addEventListener('click', () => {
|
|
confirmDelete();
|
|
});
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Settings Management (shared)
|
|
// =============================================================================
|
|
|
|
async function loadSettings() {
|
|
try {
|
|
const response = await fetch('/api/device/settings');
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
manualApprovalEnabled = data.settings.manual_add_contacts || false;
|
|
updateApprovalUI(manualApprovalEnabled);
|
|
} else {
|
|
console.error('Failed to load settings:', data.error);
|
|
showToast('Failed to load settings', 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading settings:', error);
|
|
showToast('Network error loading settings', 'danger');
|
|
}
|
|
}
|
|
|
|
async function handleApprovalToggle(event) {
|
|
const enabled = event.target.checked;
|
|
|
|
try {
|
|
const response = await fetch('/api/device/settings', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
manual_add_contacts: enabled
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
manualApprovalEnabled = enabled;
|
|
updateApprovalUI(enabled);
|
|
showToast(
|
|
enabled ? 'Manual approval enabled' : 'Manual approval disabled',
|
|
'success'
|
|
);
|
|
} else {
|
|
console.error('Failed to update setting:', data.error);
|
|
showToast('Failed to update setting: ' + data.error, 'danger');
|
|
|
|
// Revert toggle on failure
|
|
event.target.checked = !enabled;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating setting:', error);
|
|
showToast('Network error updating setting', 'danger');
|
|
|
|
// Revert toggle on failure
|
|
event.target.checked = !enabled;
|
|
}
|
|
}
|
|
|
|
function updateApprovalUI(enabled) {
|
|
const switchEl = document.getElementById('manualApprovalSwitch');
|
|
const labelEl = document.getElementById('switchLabel');
|
|
|
|
if (switchEl) {
|
|
switchEl.checked = enabled;
|
|
}
|
|
|
|
if (labelEl) {
|
|
labelEl.textContent = enabled
|
|
? 'Manual approval enabled'
|
|
: 'Automatic approval (default)';
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Protected Contacts Management
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Load protected contacts list from server.
|
|
* Called on page load to populate local state.
|
|
*/
|
|
async function loadProtectedContacts() {
|
|
try {
|
|
const response = await fetch('/api/contacts/protected');
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
protectedContacts = data.protected_contacts || [];
|
|
console.log('Loaded protected contacts:', protectedContacts.length);
|
|
} else {
|
|
console.error('Failed to load protected contacts:', data.error);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading protected contacts:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle protection status for a contact.
|
|
* @param {string} publicKey - Full public key of contact
|
|
* @param {HTMLElement} buttonEl - Button element for visual feedback
|
|
*/
|
|
async function toggleContactProtection(publicKey, buttonEl) {
|
|
const originalHTML = buttonEl.innerHTML;
|
|
buttonEl.disabled = true;
|
|
buttonEl.innerHTML = '<i class="bi bi-hourglass-split"></i>';
|
|
|
|
try {
|
|
const response = await fetch(`/api/contacts/${encodeURIComponent(publicKey)}/protect`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
// Update local state
|
|
const pkLower = publicKey.toLowerCase();
|
|
if (data.protected) {
|
|
if (!protectedContacts.includes(pkLower)) {
|
|
protectedContacts.push(pkLower);
|
|
}
|
|
} else {
|
|
protectedContacts = protectedContacts.filter(pk => pk !== pkLower);
|
|
}
|
|
|
|
// Update UI
|
|
updateProtectionUI(publicKey, data.protected, buttonEl);
|
|
|
|
showToast(data.message, 'success');
|
|
} else {
|
|
showToast('Failed to update protection: ' + data.error, 'danger');
|
|
buttonEl.innerHTML = originalHTML;
|
|
buttonEl.disabled = false;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling protection:', error);
|
|
showToast('Network error', 'danger');
|
|
buttonEl.innerHTML = originalHTML;
|
|
buttonEl.disabled = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update UI elements based on protection status.
|
|
* @param {string} publicKey - Public key of contact
|
|
* @param {boolean} isProtected - New protection status
|
|
* @param {HTMLElement} buttonEl - Protect button element
|
|
*/
|
|
function updateProtectionUI(publicKey, isProtected, buttonEl) {
|
|
const cardEl = buttonEl.closest('.existing-contact-card');
|
|
|
|
// Update button appearance
|
|
buttonEl.disabled = false;
|
|
if (isProtected) {
|
|
buttonEl.innerHTML = '<i class="bi bi-lock-fill"></i> Protected';
|
|
buttonEl.classList.remove('btn-outline-warning');
|
|
buttonEl.classList.add('btn-warning');
|
|
} else {
|
|
buttonEl.innerHTML = '<i class="bi bi-shield"></i> Protect';
|
|
buttonEl.classList.remove('btn-warning');
|
|
buttonEl.classList.add('btn-outline-warning');
|
|
}
|
|
|
|
// Update card lock indicator
|
|
const nameDiv = cardEl.querySelector('.contact-name');
|
|
let lockIcon = cardEl.querySelector('.protection-indicator');
|
|
|
|
if (isProtected) {
|
|
if (!lockIcon && nameDiv) {
|
|
const indicator = document.createElement('span');
|
|
indicator.className = 'protection-indicator ms-2';
|
|
indicator.innerHTML = '<i class="bi bi-lock-fill text-warning" title="Protected contact"></i>';
|
|
nameDiv.appendChild(indicator);
|
|
}
|
|
} else {
|
|
if (lockIcon) lockIcon.remove();
|
|
}
|
|
|
|
// Enable/disable delete button
|
|
const deleteBtn = cardEl.querySelector('.btn-outline-danger');
|
|
if (deleteBtn) {
|
|
deleteBtn.disabled = isProtected;
|
|
deleteBtn.title = isProtected ? 'Cannot delete protected contact' : '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a contact is protected.
|
|
* @param {string} publicKey - Public key to check
|
|
* @returns {boolean} True if protected
|
|
*/
|
|
function isContactProtected(publicKey) {
|
|
return protectedContacts.includes(publicKey.toLowerCase());
|
|
}
|
|
|
|
// =============================================================================
|
|
// Pending Type Filter (localStorage persistence)
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Save pending contacts type filter to localStorage.
|
|
* This allows the filter to persist across page reloads and be used
|
|
* in different parts of the app (Pending page, Contact Management badge, etc.)
|
|
*
|
|
* @param {Array<number>} types - Array of contact types to filter (1=CLI, 2=REP, 3=ROOM, 4=SENS)
|
|
*/
|
|
function savePendingTypeFilter(types) {
|
|
try {
|
|
localStorage.setItem('pendingContactsTypeFilter', JSON.stringify(types));
|
|
console.log('Pending type filter saved:', types);
|
|
} catch (e) {
|
|
console.error('Failed to save pending type filter to localStorage:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load pending contacts type filter from localStorage.
|
|
*
|
|
* @returns {Array<number>} Array of contact types (default: [1] for CLI only)
|
|
*/
|
|
function loadPendingTypeFilter() {
|
|
try {
|
|
const stored = localStorage.getItem('pendingContactsTypeFilter');
|
|
if (stored) {
|
|
const types = JSON.parse(stored);
|
|
// Validate: must be array of valid types
|
|
if (Array.isArray(types) && types.every(t => [1, 2, 3, 4].includes(t))) {
|
|
console.log('Pending type filter loaded:', types);
|
|
return types;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load pending type filter from localStorage:', e);
|
|
}
|
|
// Default: CLI only (most common use case)
|
|
return [1];
|
|
}
|
|
|
|
/**
|
|
* Set type filter badges based on types array.
|
|
* @param {Array<number>} types - Array of contact types (1=CLI, 2=REP, 3=ROOM, 4=SENS)
|
|
*/
|
|
function setTypeFilterBadges(types) {
|
|
const badges = {
|
|
1: document.getElementById('typeFilterCLI'),
|
|
2: document.getElementById('typeFilterREP'),
|
|
3: document.getElementById('typeFilterROOM'),
|
|
4: document.getElementById('typeFilterSENS')
|
|
};
|
|
|
|
// Set badges based on types array
|
|
for (const [type, badge] of Object.entries(badges)) {
|
|
if (badge) {
|
|
if (types.includes(parseInt(type))) {
|
|
badge.classList.add('active');
|
|
} else {
|
|
badge.classList.remove('active');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get currently selected contact types from badges.
|
|
* @returns {Array<number>} Array of selected types
|
|
*/
|
|
function getSelectedTypes() {
|
|
const types = [];
|
|
if (document.getElementById('typeFilterCLI')?.classList.contains('active')) types.push(1);
|
|
if (document.getElementById('typeFilterREP')?.classList.contains('active')) types.push(2);
|
|
if (document.getElementById('typeFilterROOM')?.classList.contains('active')) types.push(3);
|
|
if (document.getElementById('typeFilterSENS')?.classList.contains('active')) types.push(4);
|
|
return types;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Pending Contacts Management
|
|
// =============================================================================
|
|
|
|
async function loadPendingContacts() {
|
|
const loadingEl = document.getElementById('pendingLoading');
|
|
const emptyEl = document.getElementById('pendingEmpty');
|
|
const listEl = document.getElementById('pendingList');
|
|
const errorEl = document.getElementById('pendingError');
|
|
const countBadge = document.getElementById('pendingCountBadge');
|
|
|
|
// Show loading state
|
|
if (loadingEl) loadingEl.style.display = 'block';
|
|
if (emptyEl) emptyEl.style.display = 'none';
|
|
if (listEl) listEl.innerHTML = '';
|
|
if (errorEl) errorEl.style.display = 'none';
|
|
if (countBadge) countBadge.style.display = 'none';
|
|
|
|
try {
|
|
// Get selected types from checkboxes
|
|
const selectedTypes = getSelectedTypes();
|
|
|
|
// Build query string with types parameter
|
|
const params = new URLSearchParams();
|
|
selectedTypes.forEach(type => params.append('types', type));
|
|
|
|
const response = await fetch(`/api/contacts/pending?${params.toString()}`);
|
|
const data = await response.json();
|
|
|
|
if (loadingEl) loadingEl.style.display = 'none';
|
|
|
|
if (data.success) {
|
|
pendingContacts = data.pending || [];
|
|
|
|
if (pendingContacts.length === 0) {
|
|
// Show empty state
|
|
if (emptyEl) emptyEl.style.display = 'block';
|
|
if (countBadge) countBadge.style.display = 'none';
|
|
|
|
// Reset filtered count badge when no contacts match type filter
|
|
const filteredCountBadge = document.getElementById('filteredCountBadge');
|
|
if (filteredCountBadge) filteredCountBadge.textContent = '0';
|
|
filteredPendingContacts = [];
|
|
} else {
|
|
// Initialize filtered list and apply filters (default: CLI only)
|
|
filteredPendingContacts = [...pendingContacts];
|
|
applyPendingFilters();
|
|
|
|
// Update count badge (in navbar)
|
|
if (countBadge) {
|
|
countBadge.textContent = pendingContacts.length;
|
|
countBadge.style.display = 'inline-block';
|
|
}
|
|
}
|
|
} else {
|
|
console.error('Failed to load pending contacts:', data.error);
|
|
if (errorEl) {
|
|
const errorMsg = document.getElementById('pendingErrorMessage');
|
|
if (errorMsg) errorMsg.textContent = data.error || 'Failed to load pending contacts';
|
|
errorEl.style.display = 'block';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading pending contacts:', error);
|
|
if (loadingEl) loadingEl.style.display = 'none';
|
|
if (errorEl) {
|
|
const errorMsg = document.getElementById('pendingErrorMessage');
|
|
if (errorMsg) errorMsg.textContent = 'Network error: ' + error.message;
|
|
errorEl.style.display = 'block';
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderPendingList(contacts) {
|
|
const listEl = document.getElementById('pendingList');
|
|
if (!listEl) return;
|
|
|
|
listEl.innerHTML = '';
|
|
|
|
// Show "no filtered results" message if filters eliminate all contacts
|
|
if (contacts.length === 0 && pendingContacts.length > 0) {
|
|
const emptyDiv = document.createElement('div');
|
|
emptyDiv.className = 'empty-state';
|
|
emptyDiv.innerHTML = `
|
|
<i class="bi bi-funnel"></i>
|
|
<p class="mb-0">No contacts match filters</p>
|
|
<small class="text-muted">Try changing your filter criteria</small>
|
|
`;
|
|
listEl.appendChild(emptyDiv);
|
|
return;
|
|
}
|
|
|
|
contacts.forEach((contact, index) => {
|
|
const card = createContactCard(contact, index);
|
|
listEl.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function createContactCard(contact, index) {
|
|
const card = document.createElement('div');
|
|
card.className = 'pending-contact-card';
|
|
card.id = `contact-${index}`;
|
|
|
|
// Contact info row (name + type badge)
|
|
const infoRow = document.createElement('div');
|
|
infoRow.className = 'contact-info-row';
|
|
|
|
const nameDiv = document.createElement('div');
|
|
nameDiv.className = 'contact-name flex-grow-1';
|
|
nameDiv.textContent = contact.name;
|
|
|
|
const typeBadge = document.createElement('span');
|
|
typeBadge.className = 'badge type-badge';
|
|
typeBadge.textContent = contact.type_label || 'CLI';
|
|
|
|
// Color-code by type (same as existing contacts)
|
|
switch (contact.type_label) {
|
|
case 'CLI':
|
|
typeBadge.classList.add('bg-primary');
|
|
break;
|
|
case 'REP':
|
|
typeBadge.classList.add('bg-success');
|
|
break;
|
|
case 'ROOM':
|
|
typeBadge.classList.add('bg-info');
|
|
break;
|
|
case 'SENS':
|
|
typeBadge.classList.add('bg-warning', 'text-dark');
|
|
break;
|
|
default:
|
|
typeBadge.classList.add('bg-secondary');
|
|
}
|
|
|
|
infoRow.appendChild(nameDiv);
|
|
infoRow.appendChild(typeBadge);
|
|
|
|
// Public key row (use prefix for display)
|
|
const keyDiv = document.createElement('div');
|
|
keyDiv.className = 'contact-key';
|
|
keyDiv.textContent = contact.public_key_prefix || contact.public_key.substring(0, 12);
|
|
keyDiv.title = 'Public Key Prefix';
|
|
|
|
// Last advert (optional - show if available)
|
|
let lastAdvertDiv = null;
|
|
if (contact.last_advert) {
|
|
lastAdvertDiv = document.createElement('div');
|
|
lastAdvertDiv.className = 'text-muted small';
|
|
const relativeTime = formatRelativeTime(contact.last_advert);
|
|
lastAdvertDiv.textContent = `Last seen: ${relativeTime}`;
|
|
}
|
|
|
|
// Action buttons
|
|
const actionsDiv = document.createElement('div');
|
|
actionsDiv.className = 'd-flex gap-2 mt-2';
|
|
|
|
// Approve button
|
|
const approveBtn = document.createElement('button');
|
|
approveBtn.className = 'btn btn-sm btn-success';
|
|
approveBtn.innerHTML = '<i class="bi bi-check-circle"></i> Approve';
|
|
approveBtn.onclick = () => approveContact(contact, index);
|
|
|
|
actionsDiv.appendChild(approveBtn);
|
|
|
|
// Map button (only if GPS coordinates available)
|
|
if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) {
|
|
const mapBtn = document.createElement('button');
|
|
mapBtn.className = 'btn btn-sm btn-outline-primary';
|
|
mapBtn.innerHTML = '<i class="bi bi-geo-alt"></i> Map';
|
|
mapBtn.onclick = () => window.showContactOnMap(contact.name, contact.adv_lat, contact.adv_lon);
|
|
actionsDiv.appendChild(mapBtn);
|
|
}
|
|
|
|
// Copy key button
|
|
const copyBtn = document.createElement('button');
|
|
copyBtn.className = 'btn btn-sm btn-outline-secondary';
|
|
copyBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy Key';
|
|
copyBtn.onclick = () => copyPublicKey(contact.public_key, copyBtn);
|
|
|
|
actionsDiv.appendChild(copyBtn);
|
|
|
|
// Assemble card
|
|
card.appendChild(infoRow);
|
|
card.appendChild(keyDiv);
|
|
if (lastAdvertDiv) card.appendChild(lastAdvertDiv);
|
|
card.appendChild(actionsDiv);
|
|
|
|
return card;
|
|
}
|
|
|
|
async function approveContact(contact, index) {
|
|
const cardEl = document.getElementById(`contact-${index}`);
|
|
|
|
// Disable buttons during approval
|
|
if (cardEl) {
|
|
const buttons = cardEl.querySelectorAll('button');
|
|
buttons.forEach(btn => btn.disabled = true);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/contacts/pending/approve', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
public_key: contact.public_key // ALWAYS use full public_key (works for CLI, ROOM, etc.)
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
showToast(`Approved: ${contact.name}`, 'success');
|
|
|
|
// Remove from list with animation
|
|
if (cardEl) {
|
|
cardEl.style.opacity = '0';
|
|
cardEl.style.transition = 'opacity 0.3s';
|
|
setTimeout(() => {
|
|
cardEl.remove();
|
|
|
|
// Reload pending list to update count
|
|
loadPendingContacts();
|
|
}, 300);
|
|
}
|
|
} else {
|
|
console.error('Failed to approve contact:', data.error);
|
|
showToast('Failed to approve: ' + data.error, 'danger');
|
|
|
|
// Re-enable buttons
|
|
if (cardEl) {
|
|
const buttons = cardEl.querySelectorAll('button');
|
|
buttons.forEach(btn => btn.disabled = false);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error approving contact:', error);
|
|
showToast('Network error: ' + error.message, 'danger');
|
|
|
|
// Re-enable buttons
|
|
if (cardEl) {
|
|
const buttons = cardEl.querySelectorAll('button');
|
|
buttons.forEach(btn => btn.disabled = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
function copyPublicKey(publicKey, buttonEl) {
|
|
navigator.clipboard.writeText(publicKey).then(() => {
|
|
// Visual feedback
|
|
const originalHTML = buttonEl.innerHTML;
|
|
buttonEl.innerHTML = '<i class="bi bi-check"></i> Copied!';
|
|
buttonEl.classList.remove('btn-outline-secondary');
|
|
buttonEl.classList.add('btn-success');
|
|
|
|
setTimeout(() => {
|
|
buttonEl.innerHTML = originalHTML;
|
|
buttonEl.classList.remove('btn-success');
|
|
buttonEl.classList.add('btn-outline-secondary');
|
|
}, 2000);
|
|
|
|
showToast('Public key copied to clipboard', 'info');
|
|
}).catch(err => {
|
|
console.error('Failed to copy:', err);
|
|
showToast('Failed to copy to clipboard', 'danger');
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// Pending Page - Filtering and Batch Approval
|
|
// =============================================================================
|
|
|
|
function applyPendingFilters() {
|
|
const searchInput = document.getElementById('pendingSearchInput');
|
|
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
|
|
|
|
// Apply search filter locally (type filter already applied by API)
|
|
filteredPendingContacts = pendingContacts.filter(contact => {
|
|
// Search filter (name or public_key_prefix)
|
|
if (searchTerm) {
|
|
const nameMatch = contact.name.toLowerCase().includes(searchTerm);
|
|
const keyMatch = (contact.public_key_prefix || contact.public_key).toLowerCase().includes(searchTerm);
|
|
return nameMatch || keyMatch;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// Update filtered count badge
|
|
const countBadge = document.getElementById('filteredCountBadge');
|
|
if (countBadge) {
|
|
countBadge.textContent = filteredPendingContacts.length;
|
|
}
|
|
|
|
// Render filtered list
|
|
renderPendingList(filteredPendingContacts);
|
|
}
|
|
|
|
function showBatchApprovalModal() {
|
|
if (filteredPendingContacts.length === 0) {
|
|
showToast('No contacts to approve', 'warning');
|
|
return;
|
|
}
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('batchApprovalModal'));
|
|
const countEl = document.getElementById('batchApprovalCount');
|
|
const listEl = document.getElementById('batchApprovalList');
|
|
|
|
// Update count
|
|
if (countEl) countEl.textContent = filteredPendingContacts.length;
|
|
|
|
// Populate list
|
|
if (listEl) {
|
|
listEl.innerHTML = '';
|
|
filteredPendingContacts.forEach(contact => {
|
|
const item = document.createElement('div');
|
|
item.className = 'list-group-item d-flex justify-content-between align-items-center';
|
|
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.textContent = contact.name;
|
|
|
|
const typeBadge = document.createElement('span');
|
|
typeBadge.className = 'badge';
|
|
typeBadge.textContent = contact.type_label;
|
|
|
|
switch (contact.type_label) {
|
|
case 'CLI':
|
|
typeBadge.classList.add('bg-primary');
|
|
break;
|
|
case 'REP':
|
|
typeBadge.classList.add('bg-success');
|
|
break;
|
|
case 'ROOM':
|
|
typeBadge.classList.add('bg-info');
|
|
break;
|
|
case 'SENS':
|
|
typeBadge.classList.add('bg-warning', 'text-dark');
|
|
break;
|
|
default:
|
|
typeBadge.classList.add('bg-secondary');
|
|
}
|
|
|
|
item.appendChild(nameSpan);
|
|
item.appendChild(typeBadge);
|
|
listEl.appendChild(item);
|
|
});
|
|
}
|
|
|
|
modal.show();
|
|
}
|
|
|
|
async function batchApproveContacts() {
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('batchApprovalModal'));
|
|
const confirmBtn = document.getElementById('confirmBatchApprovalBtn');
|
|
|
|
if (confirmBtn) confirmBtn.disabled = true;
|
|
|
|
let successCount = 0;
|
|
let failedCount = 0;
|
|
const failures = [];
|
|
|
|
// Approve contacts one by one (sequential HTTP requests)
|
|
for (let i = 0; i < filteredPendingContacts.length; i++) {
|
|
const contact = filteredPendingContacts[i];
|
|
|
|
// Update button with progress
|
|
if (confirmBtn) {
|
|
confirmBtn.innerHTML = `<i class="bi bi-hourglass-split"></i> Approving ${i + 1}/${filteredPendingContacts.length}...`;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/contacts/pending/approve', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
public_key: contact.public_key
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
successCount++;
|
|
} else {
|
|
failedCount++;
|
|
failures.push({ name: contact.name, error: data.error });
|
|
}
|
|
} catch (error) {
|
|
failedCount++;
|
|
failures.push({ name: contact.name, error: error.message });
|
|
}
|
|
}
|
|
|
|
// Close modal
|
|
if (modal) modal.hide();
|
|
|
|
// Show result
|
|
if (successCount > 0 && failedCount === 0) {
|
|
showToast(`Successfully approved ${successCount} contact${successCount !== 1 ? 's' : ''}`, 'success');
|
|
} else if (successCount > 0 && failedCount > 0) {
|
|
showToast(`Approved ${successCount}, failed ${failedCount}. Check console for details.`, 'warning');
|
|
console.error('Failed approvals:', failures);
|
|
} else {
|
|
showToast(`Failed to approve contacts. Check console for details.`, 'danger');
|
|
console.error('Failed approvals:', failures);
|
|
}
|
|
|
|
// Reload pending list
|
|
loadPendingContacts();
|
|
|
|
// Re-enable button
|
|
if (confirmBtn) {
|
|
confirmBtn.disabled = false;
|
|
confirmBtn.innerHTML = '<i class="bi bi-check-circle-fill"></i> Approve All';
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Toast Notifications
|
|
// =============================================================================
|
|
|
|
function showToast(message, type = 'info') {
|
|
const toastEl = document.getElementById('contactToast');
|
|
if (!toastEl) return;
|
|
|
|
const bodyEl = toastEl.querySelector('.toast-body');
|
|
if (!bodyEl) return;
|
|
|
|
// Set message and style
|
|
bodyEl.textContent = message;
|
|
|
|
// Apply color based on type
|
|
toastEl.classList.remove('bg-success', 'bg-danger', 'bg-info', 'bg-warning');
|
|
toastEl.classList.remove('text-white');
|
|
|
|
if (type === 'success' || type === 'danger' || type === 'warning') {
|
|
toastEl.classList.add(`bg-${type}`, 'text-white');
|
|
} else if (type === 'info') {
|
|
toastEl.classList.add('bg-info', 'text-white');
|
|
}
|
|
|
|
// Show toast
|
|
const toast = new bootstrap.Toast(toastEl, {
|
|
autohide: true,
|
|
delay: 1500
|
|
});
|
|
toast.show();
|
|
}
|
|
|
|
// =============================================================================
|
|
// Existing Contacts Management
|
|
// =============================================================================
|
|
|
|
async function loadExistingContacts() {
|
|
const loadingEl = document.getElementById('existingLoading');
|
|
const emptyEl = document.getElementById('existingEmpty');
|
|
const listEl = document.getElementById('existingList');
|
|
const errorEl = document.getElementById('existingError');
|
|
|
|
// Show loading state
|
|
if (loadingEl) loadingEl.style.display = 'block';
|
|
if (emptyEl) emptyEl.style.display = 'none';
|
|
if (listEl) listEl.innerHTML = '';
|
|
if (errorEl) errorEl.style.display = 'none';
|
|
|
|
try {
|
|
// Fetch device contacts and cached contacts in parallel
|
|
const [deviceResponse, cacheResponse] = await Promise.all([
|
|
fetch('/api/contacts/detailed'),
|
|
fetch('/api/contacts/cached?format=full')
|
|
]);
|
|
const deviceData = await deviceResponse.json();
|
|
const cacheData = await cacheResponse.json();
|
|
|
|
if (loadingEl) loadingEl.style.display = 'none';
|
|
|
|
if (deviceData.success) {
|
|
const deviceContacts = deviceData.contacts || [];
|
|
const cachedContacts = (cacheData.success && cacheData.contacts) ? cacheData.contacts : [];
|
|
|
|
// Mark device contacts
|
|
const deviceKeySet = new Set(deviceContacts.map(c => c.public_key));
|
|
deviceContacts.forEach(c => { c.on_device = true; });
|
|
|
|
// Add cache-only contacts (not on device)
|
|
const cacheOnlyContacts = cachedContacts
|
|
.filter(c => !deviceKeySet.has(c.public_key))
|
|
.map(c => ({
|
|
name: c.name || 'Unknown',
|
|
public_key: c.public_key,
|
|
public_key_prefix: c.public_key_prefix || c.public_key.substring(0, 12),
|
|
type_label: '',
|
|
last_seen: c.last_seen || 0,
|
|
on_device: false,
|
|
source: c.source || 'cache'
|
|
}));
|
|
|
|
existingContacts = [...deviceContacts, ...cacheOnlyContacts];
|
|
filteredContacts = [...existingContacts];
|
|
|
|
// Update counter badge
|
|
updateCounter(deviceData.count, deviceData.limit, cachedContacts.length);
|
|
|
|
if (existingContacts.length === 0) {
|
|
if (emptyEl) emptyEl.style.display = 'block';
|
|
} else {
|
|
applySortAndFilters();
|
|
}
|
|
} else {
|
|
console.error('Failed to load existing contacts:', deviceData.error);
|
|
if (errorEl) {
|
|
const errorMsg = document.getElementById('existingErrorMessage');
|
|
if (errorMsg) errorMsg.textContent = deviceData.error || 'Failed to load contacts';
|
|
errorEl.style.display = 'block';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading existing contacts:', error);
|
|
if (loadingEl) loadingEl.style.display = 'none';
|
|
if (errorEl) {
|
|
const errorMsg = document.getElementById('existingErrorMessage');
|
|
if (errorMsg) errorMsg.textContent = 'Network error: ' + error.message;
|
|
errorEl.style.display = 'block';
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateCounter(count, limit, totalKnown) {
|
|
const counterEl = document.getElementById('contactsCounter');
|
|
if (!counterEl) return;
|
|
|
|
let text = `${count} / ${limit}`;
|
|
if (totalKnown && totalKnown > count) {
|
|
text += ` (${totalKnown} known)`;
|
|
}
|
|
counterEl.textContent = text;
|
|
counterEl.style.display = 'inline-block';
|
|
|
|
// Remove all counter classes
|
|
counterEl.classList.remove('counter-ok', 'counter-warning', 'counter-alarm');
|
|
|
|
// Apply appropriate class based on count
|
|
if (count >= 340) {
|
|
counterEl.classList.add('counter-alarm');
|
|
} else if (count >= 300) {
|
|
counterEl.classList.add('counter-warning');
|
|
} else {
|
|
counterEl.classList.add('counter-ok');
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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 sourceFilter = document.getElementById('sourceFilter');
|
|
|
|
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
|
|
const selectedType = typeFilter ? typeFilter.value : 'ALL';
|
|
const selectedSource = sourceFilter ? sourceFilter.value : 'ALL';
|
|
|
|
// First, filter contacts
|
|
filteredContacts = existingContacts.filter(contact => {
|
|
// Source filter
|
|
if (selectedSource === 'DEVICE' && !contact.on_device) return false;
|
|
if (selectedSource === 'CACHE' && contact.on_device) return false;
|
|
|
|
// Type filter (cache-only contacts have no type_label)
|
|
if (selectedType !== 'ALL') {
|
|
if (!contact.type_label || contact.type_label !== selectedType) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Search filter (name or public_key_prefix)
|
|
if (searchTerm) {
|
|
const nameMatch = contact.name.toLowerCase().includes(searchTerm);
|
|
const keyMatch = (contact.public_key_prefix || '').toLowerCase().includes(searchTerm);
|
|
return nameMatch || keyMatch;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
|
|
function renderExistingList(contacts) {
|
|
const listEl = document.getElementById('existingList');
|
|
const emptyEl = document.getElementById('existingEmpty');
|
|
|
|
if (!listEl) return;
|
|
|
|
listEl.innerHTML = '';
|
|
|
|
if (contacts.length === 0) {
|
|
if (emptyEl) emptyEl.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
if (emptyEl) emptyEl.style.display = 'none';
|
|
|
|
contacts.forEach((contact, index) => {
|
|
const card = createExistingContactCard(contact, index);
|
|
listEl.appendChild(card);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Format Unix timestamp as relative time ("5 minutes ago", "2 hours ago", etc.)
|
|
*/
|
|
function formatRelativeTime(timestamp) {
|
|
if (!timestamp) return 'Never';
|
|
|
|
const now = Math.floor(Date.now() / 1000); // Current time in Unix seconds
|
|
const diffSeconds = now - timestamp;
|
|
|
|
if (diffSeconds < 0) return 'Just now'; // Future timestamp (clock skew)
|
|
|
|
// Less than 1 minute
|
|
if (diffSeconds < 60) {
|
|
return 'Just now';
|
|
}
|
|
|
|
// Less than 1 hour
|
|
if (diffSeconds < 3600) {
|
|
const minutes = Math.floor(diffSeconds / 60);
|
|
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
|
}
|
|
|
|
// Less than 1 day
|
|
if (diffSeconds < 86400) {
|
|
const hours = Math.floor(diffSeconds / 3600);
|
|
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
|
}
|
|
|
|
// Less than 30 days
|
|
if (diffSeconds < 2592000) {
|
|
const days = Math.floor(diffSeconds / 86400);
|
|
return `${days} day${days !== 1 ? 's' : ''} ago`;
|
|
}
|
|
|
|
// Less than 1 year
|
|
if (diffSeconds < 31536000) {
|
|
const months = Math.floor(diffSeconds / 2592000);
|
|
return `${months} month${months !== 1 ? 's' : ''} ago`;
|
|
}
|
|
|
|
// More than 1 year
|
|
const years = Math.floor(diffSeconds / 31536000);
|
|
return `${years} year${years !== 1 ? 's' : ''} ago`;
|
|
}
|
|
|
|
/**
|
|
* Get activity status indicator based on last_advert timestamp
|
|
* Returns: { icon: string, color: string, title: string }
|
|
*/
|
|
function getActivityStatus(timestamp) {
|
|
if (!timestamp) {
|
|
return {
|
|
icon: '⚫',
|
|
color: '#6c757d',
|
|
title: 'Never seen'
|
|
};
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const diffSeconds = now - timestamp;
|
|
|
|
// Active (< 5 minutes)
|
|
if (diffSeconds < 300) {
|
|
return {
|
|
icon: '🟢',
|
|
color: '#28a745',
|
|
title: 'Active (advert received recently)'
|
|
};
|
|
}
|
|
|
|
// Recent (< 1 hour)
|
|
if (diffSeconds < 3600) {
|
|
return {
|
|
icon: '🟡',
|
|
color: '#ffc107',
|
|
title: 'Recent activity'
|
|
};
|
|
}
|
|
|
|
// Inactive (> 1 hour)
|
|
return {
|
|
icon: '🔴',
|
|
color: '#dc3545',
|
|
title: 'Inactive'
|
|
};
|
|
}
|
|
|
|
function createExistingContactCard(contact, index) {
|
|
const card = document.createElement('div');
|
|
card.className = 'existing-contact-card';
|
|
card.id = `existing-contact-${index}`;
|
|
|
|
// Check if contact is protected
|
|
const isProtected = contact.is_protected || isContactProtected(contact.public_key);
|
|
|
|
// Contact info row (name + type badge)
|
|
const infoRow = document.createElement('div');
|
|
infoRow.className = 'contact-info-row';
|
|
|
|
const nameDiv = document.createElement('div');
|
|
nameDiv.className = 'contact-name flex-grow-1';
|
|
nameDiv.textContent = contact.name;
|
|
|
|
// Add protection indicator if protected
|
|
if (isProtected) {
|
|
const lockIndicator = document.createElement('span');
|
|
lockIndicator.className = 'protection-indicator ms-2';
|
|
lockIndicator.innerHTML = '<i class="bi bi-lock-fill text-warning" title="Protected contact"></i>';
|
|
nameDiv.appendChild(lockIndicator);
|
|
}
|
|
|
|
// Type badge (or "Cache only" badge for non-device contacts)
|
|
if (contact.on_device === false) {
|
|
const cacheBadge = document.createElement('span');
|
|
cacheBadge.className = 'badge type-badge bg-secondary';
|
|
cacheBadge.textContent = 'Cache';
|
|
cacheBadge.title = 'Not on device - known from adverts';
|
|
infoRow.appendChild(nameDiv);
|
|
infoRow.appendChild(cacheBadge);
|
|
} else {
|
|
const typeBadge = document.createElement('span');
|
|
typeBadge.className = 'badge type-badge';
|
|
typeBadge.textContent = contact.type_label;
|
|
|
|
// Color-code by type
|
|
switch (contact.type_label) {
|
|
case 'CLI':
|
|
typeBadge.classList.add('bg-primary');
|
|
break;
|
|
case 'REP':
|
|
typeBadge.classList.add('bg-success');
|
|
break;
|
|
case 'ROOM':
|
|
typeBadge.classList.add('bg-info');
|
|
break;
|
|
case 'SENS':
|
|
typeBadge.classList.add('bg-warning');
|
|
break;
|
|
default:
|
|
typeBadge.classList.add('bg-secondary');
|
|
}
|
|
|
|
infoRow.appendChild(nameDiv);
|
|
infoRow.appendChild(typeBadge);
|
|
}
|
|
|
|
// Public key row (clickable to copy)
|
|
const keyDiv = document.createElement('div');
|
|
keyDiv.className = 'contact-key clickable-key';
|
|
keyDiv.textContent = contact.public_key_prefix;
|
|
keyDiv.title = 'Click to copy';
|
|
keyDiv.onclick = () => copyToClipboard(contact.public_key_prefix, keyDiv);
|
|
|
|
// 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);
|
|
const relativeTime = formatRelativeTime(contact.last_seen);
|
|
|
|
const statusIcon = document.createElement('span');
|
|
statusIcon.textContent = status.icon;
|
|
statusIcon.style.fontSize = '0.9rem';
|
|
statusIcon.title = status.title;
|
|
|
|
const timeText = document.createElement('span');
|
|
timeText.textContent = `Last advert: ${relativeTime}`;
|
|
|
|
lastAdvertDiv.appendChild(statusIcon);
|
|
lastAdvertDiv.appendChild(timeText);
|
|
} else {
|
|
// No last_seen data available
|
|
const statusIcon = document.createElement('span');
|
|
statusIcon.textContent = '⚫';
|
|
statusIcon.style.fontSize = '0.9rem';
|
|
|
|
const timeText = document.createElement('span');
|
|
timeText.textContent = 'Last advert: Unknown';
|
|
|
|
lastAdvertDiv.appendChild(statusIcon);
|
|
lastAdvertDiv.appendChild(timeText);
|
|
}
|
|
|
|
// Path/mode (optional)
|
|
let pathDiv = null;
|
|
if (contact.path_or_mode && contact.path_or_mode !== 'Flood') {
|
|
pathDiv = document.createElement('div');
|
|
pathDiv.className = 'text-muted small';
|
|
pathDiv.textContent = `Path: ${contact.path_or_mode}`;
|
|
}
|
|
|
|
// Action buttons (only for device contacts)
|
|
const actionsDiv = document.createElement('div');
|
|
actionsDiv.className = 'd-flex gap-2 mt-2';
|
|
|
|
if (contact.on_device !== false) {
|
|
// Map button (only if GPS coordinates available)
|
|
if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) {
|
|
const mapBtn = document.createElement('button');
|
|
mapBtn.className = 'btn btn-sm btn-outline-primary';
|
|
mapBtn.innerHTML = '<i class="bi bi-geo-alt"></i> Map';
|
|
mapBtn.onclick = () => window.showContactOnMap(contact.name, contact.adv_lat, contact.adv_lon);
|
|
actionsDiv.appendChild(mapBtn);
|
|
}
|
|
|
|
// Protect button
|
|
const protectBtn = document.createElement('button');
|
|
protectBtn.className = isProtected ? 'btn btn-sm btn-warning' : 'btn btn-sm btn-outline-warning';
|
|
protectBtn.innerHTML = isProtected
|
|
? '<i class="bi bi-lock-fill"></i> Protected'
|
|
: '<i class="bi bi-shield"></i> Protect';
|
|
protectBtn.onclick = () => toggleContactProtection(contact.public_key, protectBtn);
|
|
actionsDiv.appendChild(protectBtn);
|
|
|
|
// Delete button (disabled if protected)
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.className = 'btn btn-sm btn-outline-danger';
|
|
deleteBtn.innerHTML = '<i class="bi bi-trash"></i> Delete';
|
|
deleteBtn.onclick = () => showDeleteModal(contact);
|
|
deleteBtn.disabled = isProtected;
|
|
if (isProtected) {
|
|
deleteBtn.title = 'Cannot delete protected contact';
|
|
}
|
|
|
|
actionsDiv.appendChild(deleteBtn);
|
|
}
|
|
|
|
// Assemble card
|
|
card.appendChild(infoRow);
|
|
card.appendChild(keyDiv);
|
|
card.appendChild(lastAdvertDiv);
|
|
if (pathDiv) card.appendChild(pathDiv);
|
|
card.appendChild(actionsDiv);
|
|
|
|
return card;
|
|
}
|
|
|
|
/**
|
|
* Copy text to clipboard with fallback for HTTP contexts.
|
|
* @param {string} text - Text to copy
|
|
* @param {HTMLElement} element - Element for visual feedback
|
|
*/
|
|
function copyToClipboard(text, element) {
|
|
const originalText = element.textContent;
|
|
|
|
// Try modern clipboard API first (requires HTTPS)
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
showCopyFeedback(element, originalText);
|
|
}).catch(() => {
|
|
// Fallback to legacy method
|
|
legacyCopy(text, element, originalText);
|
|
});
|
|
} else {
|
|
// Fallback for HTTP contexts
|
|
legacyCopy(text, element, originalText);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Legacy copy method using execCommand (works on HTTP).
|
|
*/
|
|
function legacyCopy(text, element, originalText) {
|
|
const textArea = document.createElement('textarea');
|
|
textArea.value = text;
|
|
textArea.style.position = 'fixed';
|
|
textArea.style.left = '-9999px';
|
|
document.body.appendChild(textArea);
|
|
textArea.select();
|
|
|
|
try {
|
|
document.execCommand('copy');
|
|
showCopyFeedback(element, originalText);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
showToast('Failed to copy', 'danger');
|
|
}
|
|
|
|
document.body.removeChild(textArea);
|
|
}
|
|
|
|
/**
|
|
* Show visual feedback after successful copy.
|
|
*/
|
|
function showCopyFeedback(element, originalText) {
|
|
element.textContent = 'Copied!';
|
|
element.classList.add('copied');
|
|
|
|
setTimeout(() => {
|
|
element.textContent = originalText;
|
|
element.classList.remove('copied');
|
|
}, 1500);
|
|
|
|
showToast('Key copied to clipboard', 'info');
|
|
}
|
|
|
|
function showDeleteModal(contact) {
|
|
contactToDelete = contact;
|
|
|
|
// Set modal content
|
|
const modalNameEl = document.getElementById('deleteContactName');
|
|
const modalKeyEl = document.getElementById('deleteContactKey');
|
|
|
|
if (modalNameEl) modalNameEl.textContent = contact.name;
|
|
if (modalKeyEl) modalKeyEl.textContent = contact.public_key_prefix;
|
|
|
|
// Show modal
|
|
const modal = new bootstrap.Modal(document.getElementById('deleteContactModal'));
|
|
modal.show();
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (!contactToDelete) return;
|
|
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteContactModal'));
|
|
const confirmBtn = document.getElementById('confirmDeleteBtn');
|
|
|
|
// Disable button during deletion
|
|
if (confirmBtn) {
|
|
confirmBtn.disabled = true;
|
|
confirmBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting...';
|
|
}
|
|
|
|
try {
|
|
// Use contact name for deletion (meshcli remove_contact only works with name)
|
|
const selector = contactToDelete.name;
|
|
|
|
const response = await fetch('/api/contacts/delete', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
selector: selector
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
showToast(`Deleted: ${contactToDelete.name}`, 'success');
|
|
|
|
// Hide modal
|
|
if (modal) modal.hide();
|
|
|
|
// Reload contacts list
|
|
setTimeout(() => loadExistingContacts(), 500);
|
|
} else {
|
|
console.error('Failed to delete contact:', data.error);
|
|
showToast('Failed to delete: ' + data.error, 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting contact:', error);
|
|
showToast('Network error: ' + error.message, 'danger');
|
|
} finally {
|
|
// Re-enable button
|
|
if (confirmBtn) {
|
|
confirmBtn.disabled = false;
|
|
confirmBtn.innerHTML = '<i class="bi bi-trash"></i> Delete Contact';
|
|
}
|
|
contactToDelete = null;
|
|
}
|
|
}
|