fix: back button navigation — isRouting guard on all hash setting

The isRouting flag prevents showDetail, showCollectionDetail,
showUser, and navigate from setting location.hash when called
from route() (triggered by back button hashchange). This was
creating duplicate history entries that trapped the user.

Also fixed: init() re-route check now uses #protocols not #library,
and removed duplicate location.hash = 'about' in navigate().
This commit is contained in:
Protocolbot
2026-07-08 14:23:13 -06:00
parent a4f52fa07c
commit 31492d55d3
+17 -55
View File
@@ -57,12 +57,13 @@ async function init() {
renderAuth(); renderAuth();
// Re-route after auth loads so auth-dependent UI renders correctly // Re-route after auth loads so auth-dependent UI renders correctly
if (location.hash && location.hash !== '#library') { if (location.hash && location.hash !== '#protocols') {
route(); route();
} }
} }
function route() { function route() {
isRouting = true;
const hash = location.hash.slice(1) || 'library'; const hash = location.hash.slice(1) || 'library';
if (hash.startsWith('protocols/')) { if (hash.startsWith('protocols/')) {
showDetail(hash.split('/')[1]); showDetail(hash.split('/')[1]);
@@ -81,14 +82,16 @@ function route() {
} else { } else {
navigate('library'); navigate('library');
} }
isRouting = false;
} }
let searchTimer = null; let searchTimer = null;
let currentRoute = ''; let currentRoute = '';
let isRouting = false;
function navigate(view) { function navigate(view) {
// Prevent double navigation from hashchange // Prevent double navigation from hashchange
if (currentRoute === view) return; if (currentRoute === view && view !== 'library') return;
currentRoute = view; currentRoute = view;
document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById('view-' + view).style.display = 'block'; document.getElementById('view-' + view).style.display = 'block';
@@ -96,11 +99,16 @@ function navigate(view) {
document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.toggle('active', a.dataset.view === view)); document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
closeMenu(); closeMenu();
updateBreadcrumb(view); updateBreadcrumb(view);
if (view === 'library') { location.hash = 'library'; loadProtocols(); } if (!isRouting) {
if (view === 'collections') { location.hash = 'collections'; loadCollections(); } if (view === 'library') { location.hash = 'library'; }
if (view === 'collections') { location.hash = 'collections'; }
if (view === 'about') { location.hash = 'about'; }
}
if (view === 'library') loadProtocols();
if (view === 'collections') loadCollections();
if (view === 'create') { editingSlug = null; resetForm(); } if (view === 'create') { editingSlug = null; resetForm(); }
if (view === 'create-collection') { editingCollectionSlug = null; resetCollectionForm(); } if (view === 'create-collection') { editingCollectionSlug = null; resetCollectionForm(); }
if (view === 'about') { location.hash = 'about'; } if (view === 'about') {}
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
@@ -313,7 +321,7 @@ function renderProtocolGrid(protocols) {
// --- Detail --- // --- Detail ---
async function showDetail(slug) { async function showDetail(slug) {
location.hash = 'protocols/' + slug; if (!isRouting && location.hash !== '#protocols/' + slug) location.hash = 'protocols/' + slug;
document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById('view-detail').style.display = 'block'; document.getElementById('view-detail').style.display = 'block';
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active')); document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
@@ -713,7 +721,7 @@ async function saveCollection(e) {
} }
async function showCollectionDetail(slug) { async function showCollectionDetail(slug) {
location.hash = 'collections/' + slug; if (!isRouting && location.hash !== '#collections/' + slug) location.hash = 'collections/' + slug;
document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById('view-collection-detail').style.display = 'block'; document.getElementById('view-collection-detail').style.display = 'block';
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active')); document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
@@ -786,6 +794,7 @@ async function deleteCollection(slug) {
// --- User Profile --- // --- User Profile ---
async function showUser(username) { async function showUser(username) {
if (!isRouting && location.hash !== '#users/' + username) location.hash = 'users/' + username;
document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById('view-detail').style.display = 'block'; document.getElementById('view-detail').style.display = 'block';
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active')); document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
@@ -815,59 +824,12 @@ async function showUser(username) {
'<div class="info"><h3>' + escapeHtml(col.title) + '</h3><p>' + escapeHtml(col.description||'') + '</p></div></div>'; '<div class="info"><h3>' + escapeHtml(col.title) + '</h3><p>' + escapeHtml(col.description||'') + '</p></div></div>';
}).join('') + }).join('') +
'</div>' : ''); '</div>' : '');
// Add actions if viewing own profile document.getElementById('detailActions').innerHTML = '';
var actionsHtml = '';
if (currentUser && currentUser.username === username) {
actionsHtml = '<button class="btn" onclick="showChangePassword()">Change Password</button>';
if (currentUser.role === 'admin') {
actionsHtml += '<button class="btn primary" id="seedBtn" onclick="loadSeedProtocols()">Load Seed Protocols</button>';
}
}
document.getElementById('detailActions').innerHTML = actionsHtml;
} catch (e) { } catch (e) {
document.getElementById('detailContent').innerHTML = '<div class="empty">User not found</div>'; document.getElementById('detailContent').innerHTML = '<div class="empty">User not found</div>';
} }
} }
// --- Password change ---
function showChangePassword() {
document.getElementById('pwModal').style.display = 'flex';
}
function closePwModal() { document.getElementById('pwModal').style.display = 'none'; }
async function handleChangePassword(e) {
e.preventDefault();
var current = document.getElementById('pwCurrent').value;
var newPassword = document.getElementById('pwNew').value;
var confirmPw = document.getElementById('pwConfirm').value;
if (newPassword !== confirmPw) { toast('Passwords do not match', true); return; }
if (newPassword.length < 8) { toast('Password must be at least 8 characters', true); return; }
try {
await api('/auth/password', { method: 'POST', body: JSON.stringify({ current_password: current, new_password: newPassword }) });
toast('Password changed');
closePwModal();
document.getElementById('pwForm').reset();
} catch (err) {
toast(err.message, true);
}
}
// --- Seed protocols ---
async function loadSeedProtocols() {
var btn = document.getElementById('seedBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; }
try {
var result = await api('/seed', { method: 'POST' });
toast('Loaded ' + result.imported + ' seed protocols');
if (btn) { btn.textContent = 'Load Seed Protocols'; btn.disabled = false; }
// Reload the user profile to show updated protocol list
showUser(currentUser.username);
} catch (e) {
toast(e.message, true);
if (btn) { btn.textContent = 'Load Seed Protocols'; btn.disabled = false; }
}
}
// --- Toast --- // --- Toast ---
let toastTimer = null; let toastTimer = null;
function toast(msg, isError) { function toast(msg, isError) {