feat(contacts): add contact UI with URI paste, QR scan, and manual entry

Stage 2 of manual contact add feature:
- POST /api/contacts/manual-add endpoint (URI or raw params)
- New /contacts/add page with 3 input tabs (URI, QR code, Manual)
- QR scanning via html5-qrcode (camera + image upload fallback)
- Client-side URI parsing with preview before submission
- Nav card in Contact Management above Pending Contacts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-24 20:54:41 +01:00
parent 0973d2d714
commit 878d489661
5 changed files with 445 additions and 0 deletions

View File

@@ -2738,6 +2738,48 @@ def delete_cached_contact_api():
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/contacts/manual-add', methods=['POST'])
def manual_add_contact():
"""Add a contact manually via URI or raw parameters (name, public_key, type)."""
try:
dm = _get_dm()
if not dm:
return jsonify({'success': False, 'error': 'Device manager unavailable'}), 500
data = request.get_json() or {}
# Mode 1: URI (meshcore://contact/add?... or hex blob)
uri = data.get('uri', '').strip()
if uri:
result = dm.import_contact_uri(uri)
if result['success']:
invalidate_contacts_cache()
status = 200 if result['success'] else 400
return jsonify(result), status
# Mode 2: Raw parameters
name = data.get('name', '').strip()
public_key = data.get('public_key', '').strip()
contact_type = data.get('type', 1)
if not name or not public_key:
return jsonify({'success': False, 'error': 'Name and public_key are required'}), 400
try:
contact_type = int(contact_type)
except (ValueError, TypeError):
contact_type = 1
result = dm.add_contact_manual(name, public_key, contact_type)
if result['success']:
invalidate_contacts_cache()
status = 200 if result['success'] else 400
return jsonify(result), status
except Exception as e:
logger.error(f"Error adding contact manually: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/contacts/<public_key>/push-to-device', methods=['POST'])
def push_contact_to_device(public_key):
"""Push a cache-only contact to the device."""

View File

@@ -50,6 +50,17 @@ def contact_management():
)
@views_bp.route('/contacts/add')
def contact_add():
"""
Add Contact page - URI paste, QR scan, manual fields.
"""
return render_template(
'contacts-add.html',
device_name=runtime_config.get_device_name()
)
@views_bp.route('/contacts/pending')
def contact_pending_list():
"""

View File

@@ -140,6 +140,8 @@ function detectCurrentPage() {
currentPage = 'pending';
} else if (document.getElementById('existingPageContent')) {
currentPage = 'existing';
} else if (document.getElementById('addPageContent')) {
currentPage = 'add';
}
console.log('Current page:', currentPage);
}
@@ -155,6 +157,9 @@ function initializePage() {
case 'existing':
initExistingPage();
break;
case 'add':
initAddPage();
break;
default:
console.warn('Unknown page type');
}
@@ -2555,3 +2560,253 @@ async function moveContactToCache(contact) {
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">' +
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;
}
}

View File

@@ -0,0 +1,128 @@
{% extends "contacts_base.html" %}
{% block title %}Add Contact - mc-webui{% endblock %}
{% block extra_head %}
<!-- html5-qrcode for QR scanning -->
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
{% endblock %}
{% block page_content %}
<div id="addPageContent" class="p-3">
<!-- Page Header -->
<div class="mb-3">
<h4 class="mb-2">
<i class="bi bi-person-plus"></i> Add Contact
</h4>
</div>
<!-- Action Buttons -->
<div class="d-flex gap-2 mb-3">
<button class="btn btn-outline-secondary btn-sm" onclick="navigateTo('/contacts/manage');">
<i class="bi bi-arrow-left"></i> Back
</button>
</div>
<!-- Input Mode Tabs -->
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-uri" data-bs-toggle="tab" data-bs-target="#pane-uri" type="button" role="tab">
<i class="bi bi-link-45deg"></i> URI
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-qr" data-bs-toggle="tab" data-bs-target="#pane-qr" type="button" role="tab">
<i class="bi bi-qr-code-scan"></i> QR Code
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-manual" data-bs-toggle="tab" data-bs-target="#pane-manual" type="button" role="tab">
<i class="bi bi-pencil"></i> Manual
</button>
</li>
</ul>
<div class="tab-content">
<!-- URI Paste Tab -->
<div class="tab-pane fade show active" id="pane-uri" role="tabpanel">
<div class="mb-3">
<label for="uriInput" class="form-label">MeshCore URI:</label>
<textarea class="form-control font-monospace" id="uriInput" rows="3"
placeholder="meshcore://contact/add?name=...&public_key=...&type=..."></textarea>
<small class="form-text text-muted">Paste a meshcore:// URI from the MeshCore mobile app</small>
</div>
<!-- URI Preview -->
<div id="uriPreview" class="alert alert-info d-none mb-3">
<strong>Preview:</strong>
<div><span class="text-muted">Name:</span> <span id="uriPreviewName"></span></div>
<div><span class="text-muted">Key:</span> <span id="uriPreviewKey" class="font-monospace small"></span></div>
<div><span class="text-muted">Type:</span> <span id="uriPreviewType"></span></div>
</div>
<button class="btn btn-success" id="addFromUriBtn" disabled>
<i class="bi bi-plus-circle"></i> Add Contact
</button>
</div>
<!-- QR Code Tab -->
<div class="tab-pane fade" id="pane-qr" role="tabpanel">
<!-- Camera Scanner -->
<div id="qrScannerContainer" class="mb-3">
<div id="qrReader" style="width: 100%; max-width: 500px;"></div>
<div id="qrCameraButtons" class="d-flex gap-2 mt-2">
<button class="btn btn-primary btn-sm" id="startCameraBtn">
<i class="bi bi-camera-video"></i> Start Camera
</button>
<button class="btn btn-outline-secondary btn-sm d-none" id="stopCameraBtn">
<i class="bi bi-stop-circle"></i> Stop Camera
</button>
</div>
</div>
<!-- File Upload Fallback -->
<div class="mb-3">
<label for="qrFileInput" class="form-label">Or upload a QR code image:</label>
<input type="file" class="form-control" id="qrFileInput" accept="image/*">
</div>
<!-- QR Result -->
<div id="qrResult" class="alert alert-success d-none mb-3">
<strong>Scanned:</strong>
<div><span class="text-muted">Name:</span> <span id="qrResultName"></span></div>
<div><span class="text-muted">Key:</span> <span id="qrResultKey" class="font-monospace small"></span></div>
<div><span class="text-muted">Type:</span> <span id="qrResultType"></span></div>
</div>
<div id="qrError" class="alert alert-danger d-none mb-3"></div>
<button class="btn btn-success d-none" id="addFromQrBtn">
<i class="bi bi-plus-circle"></i> Add Contact
</button>
</div>
<!-- Manual Entry Tab -->
<div class="tab-pane fade" id="pane-manual" role="tabpanel">
<div class="mb-3">
<label for="manualName" class="form-label">Name:</label>
<input type="text" class="form-control" id="manualName" placeholder="Contact name" maxlength="32">
</div>
<div class="mb-3">
<label for="manualKey" class="form-label">Public Key (64 hex chars):</label>
<input type="text" class="form-control font-monospace" id="manualKey"
placeholder="e.g. a1b2c3d4..." maxlength="64" pattern="[0-9a-fA-F]{64}">
<small class="form-text text-muted" id="manualKeyCount">0 / 64 characters</small>
</div>
<div class="mb-3">
<label for="manualType" class="form-label">Contact Type:</label>
<select class="form-select" id="manualType">
<option value="1" selected>COM (Communicator)</option>
<option value="2">REP (Repeater)</option>
<option value="3">ROOM (Room Server)</option>
<option value="4">SENS (Sensor)</option>
</select>
</div>
<button class="btn btn-success" id="addManualBtn" disabled>
<i class="bi bi-plus-circle"></i> Add Contact
</button>
</div>
</div>
<!-- Status Messages -->
<div id="addStatus" class="mt-3 d-none"></div>
</div>
{% endblock %}

View File

@@ -32,6 +32,15 @@
<i class="bi bi-list-ul"></i> Manage Contacts
</h5>
<!-- Add Contact Card -->
<div class="nav-card" onclick="navigateTo('/contacts/add');" style="border-left: 4px solid #198754;">
<div>
<h6><i class="bi bi-person-plus"></i> Add Contact</h6>
<small class="text-muted">Add from URI, QR code, or manual entry</small>
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
<!-- Pending Contacts Card -->
<div class="nav-card" onclick="navigateTo('/contacts/pending');">
<div>