Merge pull request #315 from ipnet-mesh/ui/modal-save-spinner

UI polish: modal save spinners + route list sort
This commit is contained in:
JingleManSweep
2026-07-19 21:00:57 +01:00
committed by GitHub
3 changed files with 56 additions and 16 deletions
@@ -63,7 +63,7 @@ function renderAddButton(onAdd) {
</button>`;
}
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`<dialog open class="modal modal-open">
@@ -98,8 +98,8 @@ function renderChannelModal({ channel, isEdit, onSave, onCancel }) {
</label>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click=${onCancel}>${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.save')}</button>
<button type="button" class="btn btn-ghost" @click=${onCancel} ?disabled=${saving}>${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary" ?disabled=${saving}>${saving ? html`<span class="loading loading-spinner loading-sm"></span> ` : null}${t('common.save')}</button>
</div>
</form>
</div>
@@ -107,14 +107,14 @@ function renderChannelModal({ channel, isEdit, onSave, onCancel }) {
</dialog>`;
}
function renderDeleteModal({ channel, onConfirm, onCancel }) {
function renderDeleteModal({ channel, onConfirm, onCancel, saving }) {
return html`<dialog open class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">${t('channels.delete_channel')}</h3>
<p>${t('channels.delete_confirm', { name: channel.name })}</p>
<div class="modal-action">
<button class="btn btn-ghost" @click=${onCancel}>${t('common.cancel')}</button>
<button class="btn btn-error" @click=${onConfirm}>${t('common.delete')}</button>
<button class="btn btn-ghost" @click=${onCancel} ?disabled=${saving}>${t('common.cancel')}</button>
<button class="btn btn-error" @click=${onConfirm} ?disabled=${saving}>${saving ? html`<span class="loading loading-spinner loading-sm"></span> ` : null}${t('common.delete')}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button @click=${onCancel}></button></form>
@@ -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');
}
}
@@ -62,7 +62,7 @@ function renderEditTagModal() {
</div>
<div class="modal-action">
<button type="button" class="btn" id="tagEditCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.save_changes')}</button>
<button type="submit" class="btn btn-primary" id="tagEditSubmit">${t('common.save_changes')}</button>
</div>
</form>
</div>
@@ -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 = `<span class="loading loading-spinner loading-sm"></span> ${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 = `<span class="loading loading-spinner loading-sm"></span> ${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 });
}
@@ -262,7 +262,7 @@ function renderNodeSearchResult(node, onSelect) {
</li>`;
}
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 }) {
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click=${onCancel}>${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.save')}</button>
<button type="button" class="btn btn-ghost" @click=${onCancel} ?disabled=${saving}>${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary" ?disabled=${saving}>${saving ? html`<span class="loading loading-spinner loading-sm"></span> ` : null}${t('common.save')}</button>
</div>
</form>
</div>
@@ -443,7 +443,7 @@ function renderRouteModal({ modalState, onSave, onCancel }) {
</dialog>`;
}
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`<dialog open class="modal modal-open">
@@ -451,8 +451,8 @@ function renderDeleteModal({ route, onConfirm, onCancel }) {
<h3 class="font-bold text-lg mb-4">${t('routes.delete_route')}</h3>
<p>${t('routes.delete_confirm', { label })}</p>
<div class="modal-action">
<button class="btn btn-ghost" @click=${onCancel}>${t('common.cancel')}</button>
<button class="btn btn-error" @click=${onConfirm}>${t('common.delete')}</button>
<button class="btn btn-ghost" @click=${onCancel} ?disabled=${saving}>${t('common.cancel')}</button>
<button class="btn btn-error" @click=${onConfirm} ?disabled=${saving}>${saving ? html`<span class="loading loading-spinner loading-sm"></span> ` : null}${t('common.delete')}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button @click=${onCancel}></button></form>
@@ -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`
<h2 class="text-lg font-semibold mt-6 mb-3 opacity-70">${t(`routes.visibility_${vis}`)}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -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');
}
}