diff --git a/src/meshcore_hub/web/static/js/spa/pages/channels.js b/src/meshcore_hub/web/static/js/spa/pages/channels.js index f51f6f0..d1ec654 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/channels.js +++ b/src/meshcore_hub/web/static/js/spa/pages/channels.js @@ -63,7 +63,7 @@ function renderAddButton(onAdd) { `; } -function renderChannelModal({ channel, isEdit, onSave, onCancel }) { +function renderChannelModal({ channel, isEdit, onSave, onCancel, saving }) { const title = isEdit ? t('channels.edit_channel') : t('channels.add_channel'); return html` @@ -98,8 +98,8 @@ function renderChannelModal({ channel, isEdit, onSave, onCancel }) { @@ -107,14 +107,14 @@ function renderChannelModal({ channel, isEdit, onSave, onCancel }) { `; } -function renderDeleteModal({ channel, onConfirm, onCancel }) { +function renderDeleteModal({ channel, onConfirm, onCancel, saving }) { return html` @@ -186,12 +186,14 @@ export async function render(container, params, router) { isEdit: modalState.type === 'edit', onSave: handleSave, onCancel: () => { modalState = null; renderPage(channelsList); }, + saving: !!modalState.saving, }); } else if (modalState?.type === 'delete') { modalHtml = renderDeleteModal({ channel: modalState.channel, onConfirm: handleDeleteConfirm, onCancel: () => { modalState = null; renderPage(channelsList); }, + saving: !!modalState.saving, }); } @@ -258,6 +260,8 @@ export async function render(container, params, router) { } } + modalState = { ...modalState, saving: true }; + renderPage(channels); try { if (isEdit) { await apiPut(`/api/v1/channels/${modalState.channel.id}`, body); @@ -267,16 +271,22 @@ export async function render(container, params, router) { modalState = null; await refresh(); } catch (e) { + modalState = { ...modalState, saving: false }; + renderPage(channels); alert(e.message || 'Failed to save channel'); } } async function handleDeleteConfirm() { + modalState = { ...modalState, saving: true }; + renderPage(channels); try { await apiDelete(`/api/v1/channels/${modalState.channel.id}`); modalState = null; await refresh(); } catch (e) { + modalState = { ...modalState, saving: false }; + renderPage(channels); alert(e.message || 'Failed to delete channel'); } } diff --git a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js index f3ed26e..bfaed26 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js +++ b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js @@ -62,7 +62,7 @@ function renderEditTagModal() { @@ -496,6 +496,12 @@ ${canEditTags ? renderEditTagModal() : nothing}`, container); } if (errorLabel) { errorLabel.textContent = ''; errorLabel.classList.add('hidden'); } + const submitBtn = container.querySelector('#tagEditSubmit'); + const cancelBtn = container.querySelector('#tagEditCancel'); + const orig = submitBtn.innerHTML; + submitBtn.disabled = true; + cancelBtn.disabled = true; + submitBtn.innerHTML = ` ${orig}`; try { await apiPut('/api/v1/nodes/' + node.public_key + '/tags/' + encodeURIComponent(key), { value, value_type: valueType }); container.querySelector('#tagEditModal').close(); @@ -503,6 +509,10 @@ ${canEditTags ? renderEditTagModal() : nothing}`, container); router.navigate('/nodes/' + node.public_key, true); } catch (err) { if (errorLabel) { errorLabel.textContent = err.message; errorLabel.classList.remove('hidden'); } + } finally { + submitBtn.disabled = false; + cancelBtn.disabled = false; + submitBtn.innerHTML = orig; } }, { signal }); } @@ -532,6 +542,11 @@ ${canEditTags ? renderEditTagModal() : nothing}`, container); deleteConfirm.addEventListener('click', async () => { const modal = container.querySelector('#tagDeleteModal'); const key = modal._tagKey; + const cancelBtn = container.querySelector('#tagDeleteCancel'); + const orig = deleteConfirm.innerHTML; + deleteConfirm.disabled = true; + cancelBtn.disabled = true; + deleteConfirm.innerHTML = ` ${orig}`; try { await apiDelete('/api/v1/nodes/' + node.public_key + '/tags/' + encodeURIComponent(key)); modal.close(); @@ -540,6 +555,10 @@ ${canEditTags ? renderEditTagModal() : nothing}`, container); } catch (err) { modal.close(); showFlash('error', err.message); + } finally { + deleteConfirm.disabled = false; + cancelBtn.disabled = false; + deleteConfirm.innerHTML = orig; } }, { signal }); } diff --git a/src/meshcore_hub/web/static/js/spa/pages/routes.js b/src/meshcore_hub/web/static/js/spa/pages/routes.js index 7d1dac1..2a386f8 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/routes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/routes.js @@ -262,7 +262,7 @@ function renderNodeSearchResult(node, onSelect) { `; } -function renderRouteModal({ modalState, onSave, onCancel }) { +function renderRouteModal({ modalState, onSave, onCancel, saving }) { const route = modalState.route; const isEdit = modalState.isEdit; const title = isEdit ? t('routes.edit_route') : t('routes.add_route'); @@ -434,8 +434,8 @@ function renderRouteModal({ modalState, onSave, onCancel }) { @@ -443,7 +443,7 @@ function renderRouteModal({ modalState, onSave, onCancel }) { `; } -function renderDeleteModal({ route, onConfirm, onCancel }) { +function renderDeleteModal({ route, onConfirm, onCancel, saving }) { const arrow = route.reversible !== false ? '\u2194' : '\u2192'; const label = `${route.from_label} ${arrow} ${route.to_label}`; return html` @@ -451,8 +451,8 @@ function renderDeleteModal({ route, onConfirm, onCancel }) {

${t('routes.delete_route')}

${t('routes.delete_confirm', { label })}

@@ -548,9 +548,10 @@ export async function render(container, params, router) { for (const vis of VISIBILITY_ORDER) { const group = groups.get(vis); if (!group || group.length === 0) continue; - group.sort((a, b) => - (a.from_label || '').localeCompare(b.from_label || '') - ); + group.sort((a, b) => { + const cmp = (a.from_label || '').localeCompare(b.from_label || ''); + return cmp !== 0 ? cmp : (a.to_label || '').localeCompare(b.to_label || ''); + }); groupedSections.push(html`

${t(`routes.visibility_${vis}`)}

@@ -569,12 +570,14 @@ export async function render(container, params, router) { modalState, onSave: handleSave, onCancel: () => { modalState = null; renderPage(routesList); }, + saving: !!modalState.saving, }); } else if (modalState?.type === 'delete') { modalHtml = renderDeleteModal({ route: modalState.route, onConfirm: handleDeleteConfirm, onCancel: () => { modalState = null; renderPage(routesList); }, + saving: !!modalState.saving, }); } @@ -814,6 +817,8 @@ export async function render(container, params, router) { body.clear_threshold = parseInt(clearVal, 10); } + modalState = { ...modalState, saving: true }; + renderPage(routes); try { if (isEdit) { await apiPut(`/api/v1/routes/${modalState.route.id}`, body); @@ -825,16 +830,22 @@ export async function render(container, params, router) { modalState = null; await refresh(); } catch (e) { + modalState = { ...modalState, saving: false }; + renderPage(routes); alert(e.message || 'Failed to save route'); } } async function handleDeleteConfirm() { + modalState = { ...modalState, saving: true }; + renderPage(routes); try { await apiDelete(`/api/v1/routes/${modalState.route.id}`); modalState = null; await refresh(); } catch (e) { + modalState = { ...modalState, saving: false }; + renderPage(routes); alert(e.message || 'Failed to delete route'); } }