mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-03-28 17:42:45 +01:00
2813 lines
98 KiB
JavaScript
2813 lines
98 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';
|
|
} else if (document.getElementById('addPageContent')) {
|
|
currentPage = 'add';
|
|
}
|
|
console.log('Current page:', currentPage);
|
|
}
|
|
|
|
function initializePage() {
|
|
switch (currentPage) {
|
|
case 'manage':
|
|
initManagePage();
|
|
break;
|
|
case 'pending':
|
|
initPendingPage();
|
|
break;
|
|
case 'existing':
|
|
initExistingPage();
|
|
break;
|
|
case 'add':
|
|
initAddPage();
|
|
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
|
|
['typeFilterCOM', '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();
|
|
});
|
|
}
|
|
|
|
// Ignore Filtered button - show batch ignore modal
|
|
const ignoreFilteredBtn = document.getElementById('ignoreFilteredBtn');
|
|
if (ignoreFilteredBtn) {
|
|
ignoreFilteredBtn.addEventListener('click', () => {
|
|
showBatchIgnoreModal();
|
|
});
|
|
}
|
|
|
|
// Confirm Batch Approval button - approve all filtered contacts
|
|
const confirmBatchBtn = document.getElementById('confirmBatchApprovalBtn');
|
|
if (confirmBatchBtn) {
|
|
confirmBatchBtn.addEventListener('click', () => {
|
|
batchApproveContacts();
|
|
});
|
|
}
|
|
|
|
// Confirm Batch Ignore button - ignore all filtered contacts
|
|
const confirmBatchIgnoreBtn = document.getElementById('confirmBatchIgnoreBtn');
|
|
if (confirmBatchIgnoreBtn) {
|
|
confirmBatchIgnoreBtn.addEventListener('click', () => {
|
|
batchIgnoreContacts();
|
|
});
|
|
}
|
|
|
|
// Initialize Bootstrap tooltips
|
|
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
|
new bootstrap.Tooltip(el);
|
|
});
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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 dropdown
|
|
const sortSelect = document.getElementById('sortSelect');
|
|
if (sortSelect) {
|
|
sortSelect.addEventListener('change', () => {
|
|
const lastUnderscore = sortSelect.value.lastIndexOf('_');
|
|
sortBy = sortSelect.value.substring(0, lastUnderscore);
|
|
sortOrder = sortSelect.value.substring(lastUnderscore + 1);
|
|
updateURLWithSortParams();
|
|
applySortAndFilters();
|
|
});
|
|
}
|
|
|
|
// 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> <span class="btn-label">Protected</span>';
|
|
buttonEl.classList.remove('btn-outline-warning');
|
|
buttonEl.classList.add('btn-warning');
|
|
} else {
|
|
buttonEl.innerHTML = '<i class="bi bi-shield"></i> <span class="btn-label">Protect</span>';
|
|
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, ignore, and block buttons based on protection
|
|
cardEl.querySelectorAll('button').forEach(btn => {
|
|
const icon = btn.querySelector('i');
|
|
if (!icon) return;
|
|
if (icon.classList.contains('bi-trash') || icon.classList.contains('bi-eye-slash') || icon.classList.contains('bi-slash-circle')) {
|
|
btn.disabled = isProtected;
|
|
btn.title = isProtected ? 'Protected contact' : '';
|
|
}
|
|
});
|
|
}
|
|
|
|
async function toggleContactIgnore(publicKey, ignored) {
|
|
try {
|
|
const response = await fetch(`/api/contacts/${encodeURIComponent(publicKey)}/ignore`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ignored })
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
showToast(data.message, 'info');
|
|
loadExistingContacts();
|
|
loadContactCounts();
|
|
} else {
|
|
showToast('Failed: ' + data.error, 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling ignore:', error);
|
|
showToast('Network error', 'danger');
|
|
}
|
|
}
|
|
|
|
async function toggleContactBlock(publicKey, blocked) {
|
|
if (blocked && !confirm('Block this contact? Their messages will be hidden from chat.')) return;
|
|
try {
|
|
const response = await fetch(`/api/contacts/${encodeURIComponent(publicKey)}/block`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ blocked })
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
showToast(data.message, blocked ? 'warning' : 'info');
|
|
loadExistingContacts();
|
|
loadContactCounts();
|
|
} else {
|
|
showToast('Failed: ' + data.error, 'danger');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling block:', error);
|
|
showToast('Network error', 'danger');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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=COM, 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 COM 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: COM only (most common use case)
|
|
return [1];
|
|
}
|
|
|
|
/**
|
|
* Set type filter badges based on types array.
|
|
* @param {Array<number>} types - Array of contact types (1=COM, 2=REP, 3=ROOM, 4=SENS)
|
|
*/
|
|
function setTypeFilterBadges(types) {
|
|
const badges = {
|
|
1: document.getElementById('typeFilterCOM'),
|
|
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('typeFilterCOM')?.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: COM 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 || 'COM';
|
|
|
|
// Color-code by type (same as existing contacts)
|
|
switch (contact.type_label) {
|
|
case 'COM':
|
|
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 (clickable to copy)
|
|
const keyDiv = document.createElement('div');
|
|
keyDiv.className = 'contact-key clickable-key';
|
|
keyDiv.textContent = contact.public_key_prefix || contact.public_key.substring(0, 12);
|
|
keyDiv.title = 'Click to copy';
|
|
keyDiv.onclick = () => copyToClipboard(keyDiv.textContent, keyDiv);
|
|
|
|
// 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> <span class="btn-label">Approve</span>';
|
|
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> <span class="btn-label">Map</span>';
|
|
mapBtn.onclick = () => window.showContactOnMap(contact.name, contact.adv_lat, contact.adv_lon);
|
|
actionsDiv.appendChild(mapBtn);
|
|
}
|
|
|
|
// Ignore button
|
|
const ignoreBtn = document.createElement('button');
|
|
ignoreBtn.className = 'btn btn-sm btn-outline-secondary';
|
|
ignoreBtn.innerHTML = '<i class="bi bi-eye-slash"></i> <span class="btn-label">Ignore</span>';
|
|
ignoreBtn.onclick = () => {
|
|
toggleContactIgnore(contact.public_key, true).then(() => loadPendingContacts());
|
|
};
|
|
actionsDiv.appendChild(ignoreBtn);
|
|
|
|
// Block button
|
|
const blockBtn = document.createElement('button');
|
|
blockBtn.className = 'btn btn-sm btn-outline-danger';
|
|
blockBtn.innerHTML = '<i class="bi bi-slash-circle"></i> <span class="btn-label">Block</span>';
|
|
blockBtn.onclick = () => {
|
|
toggleContactBlock(contact.public_key, true).then(() => loadPendingContacts());
|
|
};
|
|
actionsDiv.appendChild(blockBtn);
|
|
|
|
// 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 COM, 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;
|
|
});
|
|
|
|
// 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 'COM':
|
|
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';
|
|
}
|
|
}
|
|
|
|
function showBatchIgnoreModal() {
|
|
if (filteredPendingContacts.length === 0) {
|
|
showToast('No contacts to ignore', 'warning');
|
|
return;
|
|
}
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('batchIgnoreModal'));
|
|
const countEl = document.getElementById('batchIgnoreCount');
|
|
const listEl = document.getElementById('batchIgnoreList');
|
|
|
|
if (countEl) countEl.textContent = filteredPendingContacts.length;
|
|
|
|
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 'COM': 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 batchIgnoreContacts() {
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('batchIgnoreModal'));
|
|
const confirmBtn = document.getElementById('confirmBatchIgnoreBtn');
|
|
|
|
if (confirmBtn) confirmBtn.disabled = true;
|
|
|
|
let successCount = 0;
|
|
let failedCount = 0;
|
|
const failures = [];
|
|
|
|
for (let i = 0; i < filteredPendingContacts.length; i++) {
|
|
const contact = filteredPendingContacts[i];
|
|
|
|
if (confirmBtn) {
|
|
confirmBtn.innerHTML = `<i class="bi bi-hourglass-split"></i> Ignoring ${i + 1}/${filteredPendingContacts.length}...`;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/contacts/${encodeURIComponent(contact.public_key)}/ignore`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ignored: true })
|
|
});
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
if (modal) modal.hide();
|
|
|
|
if (successCount > 0 && failedCount === 0) {
|
|
showToast(`Successfully ignored ${successCount} contact${successCount !== 1 ? 's' : ''}`, 'info');
|
|
} else if (successCount > 0 && failedCount > 0) {
|
|
showToast(`Ignored ${successCount}, failed ${failedCount}. Check console for details.`, 'warning');
|
|
console.error('Failed ignores:', failures);
|
|
} else {
|
|
showToast(`Failed to ignore contacts. Check console for details.`, 'danger');
|
|
console.error('Failed ignores:', failures);
|
|
}
|
|
|
|
loadPendingContacts();
|
|
|
|
if (confirmBtn) {
|
|
confirmBtn.disabled = false;
|
|
confirmBtn.innerHTML = '<i class="bi bi-eye-slash"></i> Ignore 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: c.type_label || '',
|
|
adv_lat: c.adv_lat || 0,
|
|
adv_lon: c.adv_lon || 0,
|
|
last_seen: c.last_advert || 0,
|
|
on_device: false,
|
|
source: c.source || 'cache',
|
|
is_ignored: c.is_ignored || false,
|
|
is_blocked: c.is_blocked || false,
|
|
}));
|
|
|
|
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} cached)`;
|
|
}
|
|
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 sort dropdown to reflect current sort
|
|
const sortSelect = document.getElementById('sortSelect');
|
|
if (sortSelect) {
|
|
sortSelect.value = `${sortBy}_${sortOrder}`;
|
|
}
|
|
}
|
|
|
|
function updateURLWithSortParams() {
|
|
const url = new URL(window.location);
|
|
url.searchParams.set('sort', sortBy);
|
|
url.searchParams.set('order', sortOrder);
|
|
window.history.replaceState({}, '', url);
|
|
}
|
|
|
|
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;
|
|
if (selectedSource === 'IGNORED' && !contact.is_ignored) return false;
|
|
if (selectedSource === 'BLOCKED' && !contact.is_blocked) return false;
|
|
// Hide ignored/blocked from DEVICE/CACHE views (but show in ALL)
|
|
if (selectedSource !== 'ALL' && selectedSource !== 'IGNORED' && selectedSource !== 'BLOCKED') {
|
|
if (contact.is_ignored || contact.is_blocked) 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);
|
|
|
|
// When Blocked filter is active, also show name-blocked entries
|
|
if (selectedSource === 'BLOCKED') {
|
|
loadBlockedNamesList();
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
async function loadBlockedNamesList() {
|
|
const listEl = document.getElementById('existingList');
|
|
if (!listEl) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/contacts/blocked-names-list');
|
|
const data = await response.json();
|
|
if (!data.success || !data.blocked_names || data.blocked_names.length === 0) return;
|
|
|
|
// Add a separator header
|
|
const header = document.createElement('div');
|
|
header.className = 'text-muted small fw-bold mt-3 mb-2 px-1';
|
|
header.innerHTML = '<i class="bi bi-slash-circle"></i> Blocked by name';
|
|
listEl.appendChild(header);
|
|
|
|
data.blocked_names.forEach(entry => {
|
|
const card = document.createElement('div');
|
|
card.className = 'existing-contact-card';
|
|
|
|
const infoRow = document.createElement('div');
|
|
infoRow.className = 'contact-info-row';
|
|
|
|
const nameDiv = document.createElement('div');
|
|
nameDiv.className = 'contact-name flex-grow-1';
|
|
nameDiv.textContent = entry.name;
|
|
|
|
const statusIcon = document.createElement('span');
|
|
statusIcon.className = 'ms-1';
|
|
statusIcon.style.fontSize = '0.85rem';
|
|
statusIcon.innerHTML = '<i class="bi bi-slash-circle text-danger" title="Blocked by name"></i>';
|
|
|
|
infoRow.appendChild(nameDiv);
|
|
infoRow.appendChild(statusIcon);
|
|
|
|
const actionsDiv = document.createElement('div');
|
|
actionsDiv.className = 'd-flex gap-2 mt-2';
|
|
|
|
const unblockBtn = document.createElement('button');
|
|
unblockBtn.className = 'btn btn-sm btn-outline-success';
|
|
unblockBtn.innerHTML = '<i class="bi bi-slash-circle"></i> <span class="btn-label">Unblock</span>';
|
|
unblockBtn.onclick = async () => {
|
|
try {
|
|
const resp = await fetch('/api/contacts/block-name', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: entry.name, blocked: false })
|
|
});
|
|
const result = await resp.json();
|
|
if (result.success) {
|
|
showToast(result.message, 'info');
|
|
loadExistingContacts();
|
|
} else {
|
|
showToast('Failed: ' + result.error, 'danger');
|
|
}
|
|
} catch (err) {
|
|
showToast('Network error', 'danger');
|
|
}
|
|
};
|
|
actionsDiv.appendChild(unblockBtn);
|
|
|
|
card.appendChild(infoRow);
|
|
card.appendChild(actionsDiv);
|
|
listEl.appendChild(card);
|
|
});
|
|
} catch (err) {
|
|
console.error('Error loading blocked names:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 - use type_label if available, fall back to "Cache" for unknown type
|
|
const typeBadge = document.createElement('span');
|
|
typeBadge.className = 'badge type-badge';
|
|
|
|
if (contact.type_label) {
|
|
typeBadge.textContent = contact.type_label;
|
|
switch (contact.type_label) {
|
|
case 'COM': 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');
|
|
}
|
|
} else {
|
|
typeBadge.textContent = 'Cache';
|
|
typeBadge.classList.add('bg-secondary');
|
|
typeBadge.title = 'Not on device - type unknown';
|
|
}
|
|
|
|
// Source icon (device vs cache)
|
|
const sourceIcon = document.createElement('span');
|
|
sourceIcon.className = 'ms-1';
|
|
sourceIcon.style.fontSize = '0.85rem';
|
|
if (contact.on_device !== false) {
|
|
sourceIcon.innerHTML = '<i class="bi bi-cpu text-success" title="On device"></i>';
|
|
} else {
|
|
sourceIcon.innerHTML = '<i class="bi bi-cloud text-secondary" title="Cache only"></i>';
|
|
}
|
|
|
|
// Status icon (ignored/blocked)
|
|
let statusIcon = null;
|
|
if (contact.is_blocked) {
|
|
statusIcon = document.createElement('span');
|
|
statusIcon.className = 'ms-1';
|
|
statusIcon.style.fontSize = '0.85rem';
|
|
statusIcon.innerHTML = '<i class="bi bi-slash-circle text-danger" title="Blocked"></i>';
|
|
} else if (contact.is_ignored) {
|
|
statusIcon = document.createElement('span');
|
|
statusIcon.className = 'ms-1';
|
|
statusIcon.style.fontSize = '0.85rem';
|
|
statusIcon.innerHTML = '<i class="bi bi-eye-slash text-secondary" title="Ignored"></i>';
|
|
}
|
|
|
|
infoRow.appendChild(nameDiv);
|
|
infoRow.appendChild(typeBadge);
|
|
infoRow.appendChild(sourceIcon);
|
|
if (statusIcon) infoRow.appendChild(statusIcon);
|
|
|
|
// 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/route info for device contacts
|
|
let pathDiv = null;
|
|
if (contact.on_device !== false) {
|
|
pathDiv = document.createElement('div');
|
|
pathDiv.className = 'text-muted small';
|
|
const mode = contact.path_or_mode || 'Flood';
|
|
if (mode === 'Flood') {
|
|
pathDiv.innerHTML = '<i class="bi bi-broadcast"></i> Flood';
|
|
} else if (mode === 'Direct') {
|
|
pathDiv.innerHTML = '<i class="bi bi-arrow-right-short"></i> Direct';
|
|
} else {
|
|
// mode is formatted path like "E7→DE→54→54→D8"
|
|
const hopCount = mode.split('→').length;
|
|
pathDiv.innerHTML = `<i class="bi bi-signpost-split"></i> ${mode} <span class="text-muted">(${hopCount} hops)</span>`;
|
|
}
|
|
}
|
|
|
|
// Action buttons
|
|
const actionsDiv = document.createElement('div');
|
|
actionsDiv.className = 'd-flex gap-2 mt-2';
|
|
|
|
// Map button - for ANY contact with GPS coordinates
|
|
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> <span class="btn-label">Map</span>';
|
|
mapBtn.onclick = () => window.showContactOnMap(contact.name, contact.adv_lat, contact.adv_lon);
|
|
actionsDiv.appendChild(mapBtn);
|
|
}
|
|
|
|
// Protect, Move to cache & Delete buttons (only for device contacts)
|
|
if (contact.on_device !== false) {
|
|
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> <span class="btn-label">Protected</span>'
|
|
: '<i class="bi bi-shield"></i> <span class="btn-label">Protect</span>';
|
|
protectBtn.onclick = () => toggleContactProtection(contact.public_key, protectBtn);
|
|
actionsDiv.appendChild(protectBtn);
|
|
|
|
const moveToCacheBtn = document.createElement('button');
|
|
moveToCacheBtn.className = 'btn btn-sm btn-outline-info';
|
|
moveToCacheBtn.innerHTML = '<i class="bi bi-cloud-arrow-down"></i> <span class="btn-label">To cache</span>';
|
|
moveToCacheBtn.title = 'Remove from device, keep in cache';
|
|
moveToCacheBtn.onclick = () => moveContactToCache(contact);
|
|
moveToCacheBtn.disabled = isProtected;
|
|
if (isProtected) {
|
|
moveToCacheBtn.title = 'Cannot move protected contact';
|
|
}
|
|
actionsDiv.appendChild(moveToCacheBtn);
|
|
|
|
const deleteBtn = document.createElement('button');
|
|
deleteBtn.className = 'btn btn-sm btn-outline-danger';
|
|
deleteBtn.innerHTML = '<i class="bi bi-trash"></i> <span class="btn-label">Delete</span>';
|
|
deleteBtn.onclick = () => showDeleteModal(contact);
|
|
deleteBtn.disabled = isProtected;
|
|
if (isProtected) {
|
|
deleteBtn.title = 'Cannot delete protected contact';
|
|
}
|
|
actionsDiv.appendChild(deleteBtn);
|
|
}
|
|
|
|
// Push to device & Delete buttons for cache-only contacts
|
|
if (contact.on_device === false) {
|
|
const pushToDeviceBtn = document.createElement('button');
|
|
pushToDeviceBtn.className = 'btn btn-sm btn-outline-success';
|
|
pushToDeviceBtn.innerHTML = '<i class="bi bi-cpu"></i> <span class="btn-label">To device</span>';
|
|
pushToDeviceBtn.title = 'Add this contact to the device';
|
|
pushToDeviceBtn.onclick = () => pushContactToDevice(contact);
|
|
actionsDiv.appendChild(pushToDeviceBtn);
|
|
|
|
const deleteCacheBtn = document.createElement('button');
|
|
deleteCacheBtn.className = 'btn btn-sm btn-outline-danger';
|
|
deleteCacheBtn.innerHTML = '<i class="bi bi-trash"></i> <span class="btn-label">Delete</span>';
|
|
deleteCacheBtn.onclick = () => showDeleteModal(contact);
|
|
actionsDiv.appendChild(deleteCacheBtn);
|
|
}
|
|
|
|
// Ignore/Block/Unignore/Unblock buttons
|
|
if (contact.is_blocked) {
|
|
const unblockBtn = document.createElement('button');
|
|
unblockBtn.className = 'btn btn-sm btn-outline-success';
|
|
unblockBtn.innerHTML = '<i class="bi bi-slash-circle"></i> <span class="btn-label">Unblock</span>';
|
|
unblockBtn.onclick = () => toggleContactBlock(contact.public_key, false);
|
|
actionsDiv.appendChild(unblockBtn);
|
|
} else if (contact.is_ignored) {
|
|
const unignoreBtn = document.createElement('button');
|
|
unignoreBtn.className = 'btn btn-sm btn-outline-success';
|
|
unignoreBtn.innerHTML = '<i class="bi bi-eye"></i> <span class="btn-label">Unignore</span>';
|
|
unignoreBtn.onclick = () => toggleContactIgnore(contact.public_key, false);
|
|
actionsDiv.appendChild(unignoreBtn);
|
|
} else {
|
|
const ignoreBtn = document.createElement('button');
|
|
ignoreBtn.className = 'btn btn-sm btn-outline-secondary';
|
|
ignoreBtn.innerHTML = '<i class="bi bi-eye-slash"></i> <span class="btn-label">Ignore</span>';
|
|
ignoreBtn.onclick = () => toggleContactIgnore(contact.public_key, true);
|
|
if (isProtected) {
|
|
ignoreBtn.disabled = true;
|
|
ignoreBtn.title = 'Cannot ignore protected contact';
|
|
}
|
|
actionsDiv.appendChild(ignoreBtn);
|
|
|
|
const blockBtn = document.createElement('button');
|
|
blockBtn.className = 'btn btn-sm btn-outline-danger';
|
|
blockBtn.innerHTML = '<i class="bi bi-slash-circle"></i> <span class="btn-label">Block</span>';
|
|
blockBtn.onclick = () => toggleContactBlock(contact.public_key, true);
|
|
if (isProtected) {
|
|
blockBtn.disabled = true;
|
|
blockBtn.title = 'Cannot block protected contact';
|
|
}
|
|
actionsDiv.appendChild(blockBtn);
|
|
}
|
|
|
|
// 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 different endpoint for cache-only vs device contacts
|
|
const isCacheOnly = contactToDelete.on_device === false;
|
|
const url = isCacheOnly ? '/api/contacts/cached/delete' : '/api/contacts/delete';
|
|
const body = isCacheOnly
|
|
? { public_key: contactToDelete.public_key }
|
|
: { selector: contactToDelete.public_key };
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Push to Device / Move to Cache
|
|
// =============================================================================
|
|
|
|
async function pushContactToDevice(contact) {
|
|
if (!confirm(`Push "${contact.name}" to device?`)) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/contacts/${contact.public_key}/push-to-device`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
showToast(data.message || `${contact.name} pushed to device`, 'success');
|
|
setTimeout(() => loadExistingContacts(), 500);
|
|
} else {
|
|
showToast(data.error || 'Failed to push contact', 'danger');
|
|
}
|
|
} catch (error) {
|
|
showToast('Network error: ' + error.message, 'danger');
|
|
}
|
|
}
|
|
|
|
async function moveContactToCache(contact) {
|
|
if (!confirm(`Move "${contact.name}" from device to cache?`)) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/contacts/${contact.public_key}/move-to-cache`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
showToast(data.message || `${contact.name} moved to cache`, 'success');
|
|
setTimeout(() => loadExistingContacts(), 500);
|
|
} else {
|
|
showToast(data.error || 'Failed to move contact', 'danger');
|
|
}
|
|
} catch (error) {
|
|
showToast('Network error: ' + error.message, 'danger');
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Add Contact Page
|
|
// =============================================================================
|
|
|
|
const TYPE_LABELS = {1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS'};
|
|
|
|
let html5QrCode = null;
|
|
let qrScannedUri = null;
|
|
|
|
function initAddPage() {
|
|
console.log('Initializing Add Contact page...');
|
|
|
|
// URI tab listeners
|
|
const uriInput = document.getElementById('uriInput');
|
|
uriInput.addEventListener('input', handleUriInput);
|
|
document.getElementById('addFromUriBtn').addEventListener('click', () => submitContact('uri'));
|
|
|
|
// QR tab listeners
|
|
document.getElementById('startCameraBtn').addEventListener('click', startQrCamera);
|
|
document.getElementById('stopCameraBtn').addEventListener('click', stopQrCamera);
|
|
document.getElementById('qrFileInput').addEventListener('change', handleQrFile);
|
|
document.getElementById('addFromQrBtn').addEventListener('click', () => submitContact('qr'));
|
|
|
|
// Manual tab listeners
|
|
const manualKey = document.getElementById('manualKey');
|
|
const manualName = document.getElementById('manualName');
|
|
manualKey.addEventListener('input', handleManualKeyInput);
|
|
manualName.addEventListener('input', validateManualForm);
|
|
document.getElementById('addManualBtn').addEventListener('click', () => submitContact('manual'));
|
|
|
|
// Stop camera when switching away from QR tab
|
|
document.getElementById('tab-qr').addEventListener('hidden.bs.tab', stopQrCamera);
|
|
}
|
|
|
|
/**
|
|
* Parse a meshcore:// mobile app URI client-side for preview.
|
|
* Returns {name, public_key, type} or null.
|
|
*/
|
|
function parseMeshcoreUri(uri) {
|
|
if (!uri || !uri.startsWith('meshcore://')) return null;
|
|
try {
|
|
const url = new URL(uri);
|
|
if (url.hostname !== 'contact' || url.pathname !== '/add') return null;
|
|
const name = url.searchParams.get('name');
|
|
const publicKey = url.searchParams.get('public_key');
|
|
if (!name || !publicKey) return null;
|
|
const key = publicKey.trim().toLowerCase();
|
|
if (key.length !== 64 || !/^[0-9a-f]{64}$/.test(key)) return null;
|
|
let type = parseInt(url.searchParams.get('type') || '1', 10);
|
|
if (![1,2,3,4].includes(type)) type = 1;
|
|
return { name: name.trim(), public_key: key, type };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// --- URI Tab ---
|
|
|
|
function handleUriInput() {
|
|
const uri = document.getElementById('uriInput').value.trim();
|
|
const preview = document.getElementById('uriPreview');
|
|
const btn = document.getElementById('addFromUriBtn');
|
|
|
|
// Try mobile app format first
|
|
const parsed = parseMeshcoreUri(uri);
|
|
if (parsed) {
|
|
document.getElementById('uriPreviewName').textContent = parsed.name;
|
|
document.getElementById('uriPreviewKey').textContent = parsed.public_key;
|
|
document.getElementById('uriPreviewType').textContent = TYPE_LABELS[parsed.type] || 'COM';
|
|
preview.classList.remove('d-none');
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
// Hex blob format — can't preview but still valid
|
|
if (uri.startsWith('meshcore://') && uri.length > 20) {
|
|
preview.classList.add('d-none');
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
preview.classList.add('d-none');
|
|
btn.disabled = true;
|
|
}
|
|
|
|
// --- QR Tab ---
|
|
|
|
function startQrCamera() {
|
|
const readerEl = document.getElementById('qrReader');
|
|
if (!readerEl) return;
|
|
|
|
html5QrCode = new Html5Qrcode('qrReader');
|
|
html5QrCode.start(
|
|
{ facingMode: 'environment' },
|
|
{ fps: 10, qrbox: { width: 250, height: 250 } },
|
|
onQrCodeSuccess,
|
|
() => {} // ignore scan failures
|
|
).then(() => {
|
|
document.getElementById('startCameraBtn').classList.add('d-none');
|
|
document.getElementById('stopCameraBtn').classList.remove('d-none');
|
|
}).catch(err => {
|
|
showQrError('Camera access denied or not available. Try uploading an image instead.');
|
|
console.error('QR camera error:', err);
|
|
});
|
|
}
|
|
|
|
function stopQrCamera() {
|
|
if (html5QrCode && html5QrCode.isScanning) {
|
|
html5QrCode.stop().catch(() => {});
|
|
}
|
|
document.getElementById('startCameraBtn').classList.remove('d-none');
|
|
document.getElementById('stopCameraBtn').classList.add('d-none');
|
|
}
|
|
|
|
function handleQrFile(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
const scanner = new Html5Qrcode('qrReader');
|
|
scanner.scanFile(file, true)
|
|
.then(decodedText => {
|
|
onQrCodeSuccess(decodedText);
|
|
scanner.clear();
|
|
})
|
|
.catch(err => {
|
|
showQrError('Could not read QR code from image. Make sure the image contains a valid QR code.');
|
|
console.error('QR file scan error:', err);
|
|
});
|
|
}
|
|
|
|
function onQrCodeSuccess(decodedText) {
|
|
const resultDiv = document.getElementById('qrResult');
|
|
const errorDiv = document.getElementById('qrError');
|
|
const addBtn = document.getElementById('addFromQrBtn');
|
|
|
|
errorDiv.classList.add('d-none');
|
|
|
|
const parsed = parseMeshcoreUri(decodedText);
|
|
if (parsed) {
|
|
document.getElementById('qrResultName').textContent = parsed.name;
|
|
document.getElementById('qrResultKey').textContent = parsed.public_key;
|
|
document.getElementById('qrResultType').textContent = TYPE_LABELS[parsed.type] || 'COM';
|
|
resultDiv.classList.remove('d-none');
|
|
addBtn.classList.remove('d-none');
|
|
qrScannedUri = decodedText;
|
|
stopQrCamera();
|
|
return;
|
|
}
|
|
|
|
// Hex blob format
|
|
if (decodedText.startsWith('meshcore://') && decodedText.length > 20) {
|
|
resultDiv.innerHTML = '<strong>Scanned:</strong> <span class="font-monospace small" style="word-break: break-all;">' +
|
|
decodedText.substring(0, 60) + '...</span>';
|
|
resultDiv.classList.remove('d-none');
|
|
addBtn.classList.remove('d-none');
|
|
qrScannedUri = decodedText;
|
|
stopQrCamera();
|
|
return;
|
|
}
|
|
|
|
showQrError('QR code does not contain a valid meshcore:// URI.');
|
|
}
|
|
|
|
function showQrError(msg) {
|
|
const errorDiv = document.getElementById('qrError');
|
|
errorDiv.textContent = msg;
|
|
errorDiv.classList.remove('d-none');
|
|
document.getElementById('qrResult').classList.add('d-none');
|
|
document.getElementById('addFromQrBtn').classList.add('d-none');
|
|
}
|
|
|
|
// --- Manual Tab ---
|
|
|
|
function handleManualKeyInput() {
|
|
const input = document.getElementById('manualKey');
|
|
// Allow only hex characters
|
|
input.value = input.value.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
|
|
document.getElementById('manualKeyCount').textContent = `${input.value.length} / 64 characters`;
|
|
validateManualForm();
|
|
}
|
|
|
|
function validateManualForm() {
|
|
const name = document.getElementById('manualName').value.trim();
|
|
const key = document.getElementById('manualKey').value.trim();
|
|
const btn = document.getElementById('addManualBtn');
|
|
btn.disabled = !(name.length > 0 && key.length === 64 && /^[0-9a-f]{64}$/.test(key));
|
|
}
|
|
|
|
// --- Submit ---
|
|
|
|
async function submitContact(mode) {
|
|
const statusDiv = document.getElementById('addStatus');
|
|
let body = {};
|
|
|
|
if (mode === 'uri') {
|
|
body.uri = document.getElementById('uriInput').value.trim();
|
|
} else if (mode === 'qr') {
|
|
body.uri = qrScannedUri;
|
|
} else if (mode === 'manual') {
|
|
body.name = document.getElementById('manualName').value.trim();
|
|
body.public_key = document.getElementById('manualKey').value.trim();
|
|
body.type = parseInt(document.getElementById('manualType').value, 10);
|
|
}
|
|
|
|
// Show loading
|
|
statusDiv.className = 'mt-3 alert alert-info';
|
|
statusDiv.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Adding contact...';
|
|
statusDiv.classList.remove('d-none');
|
|
|
|
try {
|
|
const response = await fetch('/api/contacts/manual-add', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
statusDiv.className = 'mt-3 alert alert-success';
|
|
statusDiv.textContent = data.message || 'Contact added successfully!';
|
|
// Reset form
|
|
resetAddForm(mode);
|
|
} else {
|
|
statusDiv.className = 'mt-3 alert alert-danger';
|
|
statusDiv.textContent = data.error || 'Failed to add contact.';
|
|
}
|
|
} catch (error) {
|
|
statusDiv.className = 'mt-3 alert alert-danger';
|
|
statusDiv.textContent = 'Network error: ' + error.message;
|
|
}
|
|
}
|
|
|
|
function resetAddForm(mode) {
|
|
if (mode === 'uri') {
|
|
document.getElementById('uriInput').value = '';
|
|
document.getElementById('uriPreview').classList.add('d-none');
|
|
document.getElementById('addFromUriBtn').disabled = true;
|
|
} else if (mode === 'qr') {
|
|
qrScannedUri = null;
|
|
document.getElementById('qrResult').classList.add('d-none');
|
|
document.getElementById('addFromQrBtn').classList.add('d-none');
|
|
document.getElementById('qrFileInput').value = '';
|
|
} else if (mode === 'manual') {
|
|
document.getElementById('manualName').value = '';
|
|
document.getElementById('manualKey').value = '';
|
|
document.getElementById('manualKeyCount').textContent = '0 / 64 characters';
|
|
document.getElementById('addManualBtn').disabled = true;
|
|
}
|
|
}
|