fix: resolve mobile UI issues on members page and node detail

- Replace nested <a> tags in member cards with @click handlers to fix
  invalid HTML causing missing badge styles and double-navigation
  race conditions (NS_BINDING_ABORT) on mobile
- Add e.preventDefault() to @click handlers to prevent parent <a>
  activation
- Bump mobile dropdown z-index from z-[1] to z-50 so menu items
  render above QR code wrapper (z-20) on node detail hero section
- Blur active element after SPA navigation to dismiss DaisyUI
  :focus-within dropdown state
- Fix dashboard channel messages to show short date+time format
- Add overflow-x-auto to custom pages and node detail panels for
  mobile overflow
- Fix tag editor: hide type column on mobile, truncate key/value
  cells, show key in modal title instead of disabled input field
- Fix delete tag dialog using innerHTML for <strong> in i18n strings
- Fix tag add form not resetting after successful submit
- Add break-word and overflow-x-auto to prose tables in app.css
This commit is contained in:
Louis King
2026-05-04 11:00:04 +01:00
parent ddbbe05e54
commit b84cc8894f
7 changed files with 39 additions and 23 deletions
+7
View File
@@ -256,10 +256,17 @@
font-style: italic;
}
.prose {
overflow-wrap: break-word;
word-break: break-word;
}
.prose table {
width: 100%;
margin-bottom: 1rem;
border-collapse: collapse;
display: block;
overflow-x: auto;
}
.prose th,
@@ -12,7 +12,7 @@ export async function render(container, params, router) {
litRender(html`
<div class="max-w-4xl mx-auto">
<div class="card bg-base-100 shadow-xl">
<div class="card-body prose prose-lg max-w-none">
<div class="card-body prose prose-lg max-w-none overflow-x-auto">
${unsafeHTML(page.content_html)}
</div>
</div>
@@ -25,6 +25,7 @@ function formatTimeOnly(isoString) {
function formatTimeShort(isoString) {
return formatDateTime(isoString, {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
hour12: false,
});
@@ -2,7 +2,7 @@ import { apiGet } from '../api.js';
import { html, litRender, nothing, t, errorAlert, getConfig } from '../components.js';
import { iconAntenna, iconUsers } from '../icons.js';
function renderProfileTile(profile) {
function renderProfileTile(profile, router) {
const callsignBadge = profile.callsign
? html`<span class="badge badge-neutral badge-sm">${profile.callsign}</span>`
: nothing;
@@ -20,7 +20,12 @@ function renderProfileTile(profile) {
const nodeBadges = profile.adopted_nodes && profile.adopted_nodes.length > 0
? html`<div class="flex flex-wrap gap-1 mt-2">${profile.adopted_nodes.map(node => {
const label = node.name || node.public_key.slice(0, 12) + '...';
return html`<a href="/nodes/${node.public_key}" class="badge badge-outline badge-sm hover:badge-ghost transition-colors">${label}</a>`;
const handleClick = (e) => {
e.preventDefault();
e.stopPropagation();
router.navigate('/nodes/' + node.public_key);
};
return html`<span class="badge badge-secondary badge-sm cursor-pointer" @click=${handleClick}>${label}</span>`;
})}</div>`
: nothing;
@@ -29,7 +34,7 @@ function renderProfileTile(profile) {
: nothing;
const urlLink = profile.url
? html`<a href="${profile.url}" target="_blank" rel="noopener noreferrer" class="link link-primary text-xs mt-1 inline-block truncate">${profile.url}</a>`
? html`<span class="link link-accent text-xs mt-1 inline-block truncate cursor-pointer" @click=${(e) => { e.preventDefault(); e.stopPropagation(); window.open(profile.url, '_blank', 'noopener,noreferrer'); }}>${profile.url}</span>`
: nothing;
return html`<a href="/profile/${profile.id}" class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
@@ -47,14 +52,14 @@ function renderProfileTile(profile) {
</a>`;
}
function renderGroup(title, profiles, icon) {
function renderGroup(title, profiles, icon, router) {
if (profiles.length === 0) return nothing;
return html`
<h2 class="text-2xl font-bold mt-8 mb-4 flex items-center gap-2">
${icon}${title}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${profiles.sort((a, b) => (a.name || '').localeCompare(b.name || '')).map(renderProfileTile)}
${profiles.sort((a, b) => (a.name || '').localeCompare(b.name || '')).map(p => renderProfileTile(p, router))}
</div>`;
}
@@ -92,8 +97,8 @@ export async function render(container, params, router) {
<span class="badge badge-lg">${t('common.count_entity', { count: profiles.length, entity: t('entities.members').toLowerCase() })}</span>
</div>
${renderGroup(t('members_page.operators'), operators, html`<span class="text-primary">${iconAntenna('h-6 w-6')}</span>`)}
${renderGroup(t('members_page.members'), members, html`<span class="text-secondary">${iconUsers('h-6 w-6')}</span>`)}
${renderGroup(t('members_page.operators'), operators, html`<span class="text-primary">${iconAntenna('h-6 w-6')}</span>`, router)}
${renderGroup(t('members_page.members'), members, html`<span class="text-secondary">${iconUsers('h-6 w-6')}</span>`, router)}
`, container);
} catch (e) {
@@ -44,12 +44,9 @@ function renderEditTagModal() {
return html`
<dialog id="tagEditModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.edit_entity', { entity: t('entities.tag') })}</h3>
<h3 class="font-bold text-lg">${t('common.edit_entity', { entity: t('entities.tag') })}: <span id="tagEditKeyDisplay" class="font-mono text-base font-normal"></span></h3>
<form id="tag-edit-form" class="py-4">
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('common.key')}</span></label>
<input type="text" id="tagEditKey" class="input input-bordered" disabled>
</div>
<input type="hidden" id="tagEditKey">
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('common.value')}</span></label>
<input type="text" id="tagEditValue" class="input input-bordered">
@@ -154,11 +151,11 @@ export async function render(container, params, router) {
? html`<span class="opacity-50">-</span>`
: recvName
? html`<a href="/nodes/${adv.observed_by}" class="link link-hover">
<div class="font-medium text-sm">${recvName}</div>
<div class="text-xs font-mono opacity-70">${adv.observed_by.slice(0, 16)}...</div>
<div class="font-medium text-sm truncate max-w-[8rem]">${recvName}</div>
<div class="text-xs font-mono opacity-70 hidden sm:block">${adv.observed_by.slice(0, 16)}...</div>
</a>`
: html`<a href="/nodes/${adv.observed_by}" class="link link-hover">
<span class="font-mono text-xs">${adv.observed_by.slice(0, 16)}...</span>
<span class="font-mono text-xs">${adv.observed_by.slice(0, 12)}...</span>
</a>`;
return html`<tr>
<td class="text-xs whitespace-nowrap">${formatDateTime(adv.received_at)}</td>
@@ -184,15 +181,15 @@ export async function render(container, params, router) {
<tr>
<th>${t('common.key')}</th>
<th>${t('common.value')}</th>
<th>${t('common.type')}</th>
<th class="w-20">${t('common.actions')}</th>
<th class="hidden sm:table-cell">${t('common.type')}</th>
<th class="w-16">${t('common.actions')}</th>
</tr>
</thead>
<tbody>
${tags.map(tag => html`<tr>
<td class="font-mono">${tag.key}</td>
<td>${tag.value || ''}</td>
<td class="opacity-70">${tag.value_type || 'string'}</td>
<td class="font-mono min-w-0 truncate max-w-[8rem]">${tag.key}</td>
<td class="min-w-0 truncate max-w-[12rem]">${tag.value || ''}</td>
<td class="hidden sm:table-cell opacity-70">${tag.value_type || 'string'}</td>
<td>
<div class="flex gap-1">
<button class="btn btn-xs btn-ghost tag-edit-btn" data-key=${tag.key} data-value=${tag.value || ''} data-type=${tag.value_type || 'string'}>${iconEdit('h-4 w-4')}</button>
@@ -454,6 +451,7 @@ ${canEditTags ? renderEditTagModal() : nothing}`, container);
try {
await apiPost('/api/v1/nodes/' + node.public_key + '/tags', { key, value, value_type: valueType });
showFlash('success', t('common.entity_added_success', { entity: t('entities.tag') }));
addForm.reset();
router.navigate('/nodes/' + node.public_key, true);
} catch (err) {
showFlash('error', err.message);
@@ -466,11 +464,13 @@ ${canEditTags ? renderEditTagModal() : nothing}`, container);
btn.addEventListener('click', () => {
const modal = container.querySelector('#tagEditModal');
const keyInput = container.querySelector('#tagEditKey');
const keyDisplay = container.querySelector('#tagEditKeyDisplay');
const valueInput = container.querySelector('#tagEditValue');
const typeSelect = container.querySelector('#tagEditType');
const errorLabel = container.querySelector('#tagEditError');
keyInput.value = btn.dataset.key;
if (keyDisplay) keyDisplay.textContent = btn.dataset.key;
valueInput.value = btn.dataset.value;
typeSelect.value = btn.dataset.type;
if (errorLabel) errorLabel.innerHTML = '';
@@ -519,7 +519,7 @@ ${canEditTags ? renderEditTagModal() : nothing}`, container);
btn.addEventListener('click', () => {
const modal = container.querySelector('#tagDeleteModal');
const msg = container.querySelector('#tag-delete-msg');
msg.textContent = t('common.delete_entity_confirm', { entity: t('entities.tag'), name: btn.dataset.key });
msg.innerHTML = t('common.delete_entity_confirm', { entity: t('entities.tag'), name: btn.dataset.key });
modal._tagKey = btn.dataset.key;
modal.showModal();
}, { signal });
@@ -122,6 +122,9 @@ export class Router {
if (loader) loader.classList.add('hidden');
}
// Reset focus to dismiss any open dropdown after navigation
document.activeElement?.blur();
// Scroll to top on navigation
window.scrollTo(0, 0);
}
+1 -1
View File
@@ -64,7 +64,7 @@
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg>
</div>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-50 p-2 shadow bg-base-100 rounded-box w-52">
<li><a href="/" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /></svg> {{ t('entities.home') }}</a></li>
{% if features.dashboard %}
<li><a href="/dashboard" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-dashboard" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> {{ t('entities.dashboard') }}</a></li>