mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-03-28 17:42:45 +01:00
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:
@@ -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."""
|
||||
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
128
app/templates/contacts-add.html
Normal file
128
app/templates/contacts-add.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user