feat(regions): Settings > Channels tab with region registry CRUD

Third slice — users can now curate their device-wide region list. No
per-channel mapping yet; that's PR #4.

- base.html: new Channels tab in the Settings modal with an info banner
  pointing at regions.meshcore.nz, the list container, and an add-region
  form.
- app.js: loadRegions / addRegion / deleteRegion / setDefaultRegion
  mirroring the loadContactsSettings / saveContactsSetting pattern. Client
  -side name validation (isValidRegionName) mirrors the firmware
  RegionMap::is_name_char byte-rule exactly so users get instant feedback
  on invalid chars without a round-trip.
- api.py: four routes under /api/regions —
  - GET    /api/regions                       → list registry
  - POST   /api/regions   {name}              → derive key + insert; 409 dup
  - DELETE /api/regions/<id>                  → cascade channel mappings; if
      the deleted region was firmware default, best-effort clear on device
  - POST   /api/regions/<id>/default          → flip DB flag + push CMD 63;
      if firmware push fails, DB still flips and response includes a
      non-blocking `warning` for a toast
This commit is contained in:
MarekWo
2026-04-24 07:24:15 +02:00
parent 0e38e0ce8c
commit f04f0f1dd8
3 changed files with 296 additions and 0 deletions
+141
View File
@@ -2414,6 +2414,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadChatSettings();
loadUiSettings();
loadContactsSettings();
loadRegions();
});
settingsModal.addEventListener('shown.bs.modal', () => {
settingsModal.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
@@ -2436,6 +2437,18 @@ document.addEventListener('DOMContentLoaded', () => {
// Initial load so suppress flag is available before user opens Settings
loadContactsSettings();
// Channels tab: region registry
const addRegionForm = document.getElementById('addRegionForm');
if (addRegionForm) {
addRegionForm.addEventListener('submit', (e) => {
e.preventDefault();
const input = document.getElementById('newRegionName');
const name = (input?.value || '').trim();
if (!name) return;
addRegion(name, input);
});
}
const dmRetryForm = document.getElementById('dmRetrySettingsForm');
if (dmRetryForm) {
dmRetryForm.addEventListener('submit', (e) => {
@@ -2793,6 +2806,134 @@ async function saveContactsSetting(key, value, inputEl) {
}
}
// ================================================================
// Region Registry (Settings > Channels)
// ================================================================
// Mirrors the firmware RegionMap::is_name_char rule: '-', '$', '#',
// digits, or any byte >= 'A'. UTF-8 bytes >= 0x80 pass via byte >= 'A'.
function isValidRegionName(name) {
if (!name || typeof name !== 'string') return false;
const bytes = new TextEncoder().encode(name);
if (bytes.length === 0 || bytes.length > 30) return false;
for (const b of bytes) {
if (b === 0x2d || b === 0x24 || b === 0x23) continue; // - $ #
if (b >= 0x30 && b <= 0x39) continue; // digits
if (b >= 0x41) continue; // >= 'A'
return false;
}
return true;
}
async function loadRegions() {
const listEl = document.getElementById('regionsList');
if (!listEl) return;
try {
const resp = await fetch('/api/regions');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (!data.success) throw new Error(data.error || 'Failed');
window.regionRegistry = data.regions || [];
renderRegionsList();
} catch (e) {
console.error('Error loading regions:', e);
listEl.innerHTML = '<div class="text-center text-danger small py-2">Failed to load regions</div>';
}
}
function renderRegionsList() {
const listEl = document.getElementById('regionsList');
if (!listEl) return;
const regions = window.regionRegistry || [];
if (regions.length === 0) {
listEl.innerHTML = '<div class="text-center text-muted small py-3">No regions defined. Add one below.</div>';
return;
}
listEl.innerHTML = regions.map(r => {
const isDefault = r.is_default ? 'checked' : '';
const keyShort = (r.key_hex || '').slice(0, 8) + '…';
return `
<div class="list-group-item d-flex align-items-center gap-2 py-2">
<div class="form-check mb-0">
<input class="form-check-input" type="radio" name="regionDefault"
id="regionDefault_${r.id}" ${isDefault}
onchange="setDefaultRegion(${r.id})">
</div>
<div class="flex-grow-1">
<div><strong>${escapeHtml(r.name)}</strong></div>
<code class="small text-muted" title="${escapeHtml(r.key_hex)}">${keyShort}</code>
</div>
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="deleteRegion(${r.id}, '${escapeHtml(r.name).replace(/'/g, "\\'")}')"
title="Delete region">
<i class="bi bi-trash"></i>
</button>
</div>
`;
}).join('');
}
async function addRegion(name, inputEl) {
if (!isValidRegionName(name)) {
showNotification('Invalid region name. Allowed: letters, digits, - $ # (max 30 bytes, no spaces).', 'warning');
return;
}
try {
const resp = await fetch('/api/regions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
showNotification(data.error || 'Failed to add region', 'danger');
return;
}
if (inputEl) inputEl.value = '';
await loadRegions();
} catch (e) {
console.error('Error adding region:', e);
showNotification('Network error adding region', 'danger');
}
}
async function deleteRegion(id, name) {
if (!confirm(`Delete region "${name}"?\nChannels using this region will revert to no scope.`)) return;
try {
const resp = await fetch(`/api/regions/${id}`, { method: 'DELETE' });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
showNotification(data.error || 'Failed to delete region', 'danger');
return;
}
await loadRegions();
} catch (e) {
console.error('Error deleting region:', e);
showNotification('Network error deleting region', 'danger');
}
}
async function setDefaultRegion(id) {
try {
const resp = await fetch(`/api/regions/${id}/default`, { method: 'POST' });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
showNotification(data.error || 'Failed to set default region', 'danger');
await loadRegions(); // snap UI back to server truth
return;
}
if (data.warning) {
showNotification(data.warning, 'warning');
}
// Update the local cache in place so radio stays checked without flicker.
(window.regionRegistry || []).forEach(r => { r.is_default = (r.id === id) ? 1 : 0; });
} catch (e) {
console.error('Error setting default region:', e);
showNotification('Network error setting default', 'danger');
await loadRegions();
}
}
/**
* Send browser notification when new messages arrive
* @param {number} channelCount - Number of channels with new messages