fix: rename 'date added' to 'most recent', simplify date label on detail pages

- Sort toggle now says 'most recent' instead of 'date added'
- Sort logic uses index position instead of text matching
- Detail pages show just the date without 'added' prefix
This commit is contained in:
Protocolbot
2026-07-07 10:39:23 -06:00
parent 1148c19fd6
commit 7e00656feb
2 changed files with 104 additions and 137 deletions
+99 -109
View File
@@ -1,6 +1,7 @@
/* Protocol Droid — Frontend Application */ /* Protocol Droid — Frontend Application */
/* Detect base path from the script's own URL for subdirectory deployments */ /* Detect base path from the script's own URL for subdirectory deployments */
/* app.js is loaded from <base>/frontend/app.js, so base = everything before /frontend/app.js */
(function() { (function() {
var scripts = document.getElementsByTagName('script'); var scripts = document.getElementsByTagName('script');
for (var i = 0; i < scripts.length; i++) { for (var i = 0; i < scripts.length; i++) {
@@ -36,40 +37,9 @@ async function api(path, options = {}) {
return data; return data;
} }
// --- Shared rendering functions (use everywhere to ensure consistency) ---
function renderProtocolPill(p) {
var sourceLabel = '';
if (p.source && p.source !== '@' + (p.author || '')) {
sourceLabel = p.source;
} else if (p.author) {
sourceLabel = '@' + p.author;
}
var tags = (p.tags || []).map(function(t) { return '<span class="tag">' + escapeHtml(t) + '</span>'; }).join('');
return '<div class="protocol-pill" onclick="showDetail(\'' + p.slug + '\')">' +
'<div class="info"><h3>' + escapeHtml(p.title) + '</h3>' +
'<p>' + escapeHtml(p.description || '') + '</p>' +
(tags ? '<div class="tag-row">' + tags + '</div>' : '') + '</div>' +
'<div class="meta"><div class="steps">' + (p.steps || []).length + ' steps</div>' +
(sourceLabel ? '<div>' + escapeHtml(sourceLabel) + '</div>' : '') + '</div></div>';
}
function renderCollectionPill(c) {
var sourceLabel = '';
if (c.source && c.source !== '@' + (c.author || '')) {
sourceLabel = c.source;
} else if (c.author) {
sourceLabel = '@' + c.author;
}
return '<div class="protocol-pill collection" onclick="showCollectionDetail(\'' + c.slug + '\')">' +
'<div class="info"><h3>' + escapeHtml(c.title) + '</h3>' +
'<p>' + escapeHtml(c.description || '') + '</p></div>' +
'<div class="meta"><div class="steps">' + (c.item_count || 0) + ' protocols</div>' +
(sourceLabel ? '<div>' + escapeHtml(sourceLabel) + '</div>' : '') + '</div></div>';
}
// --- Init --- // --- Init ---
async function init() { async function init() {
// Route immediately — don't wait for API calls
route(); route();
window.addEventListener('hashchange', route); window.addEventListener('hashchange', route);
@@ -86,31 +56,28 @@ async function init() {
} catch (e) {} } catch (e) {}
renderAuth(); renderAuth();
if (location.hash && location.hash !== '#protocols') { // Re-route after auth loads so auth-dependent UI renders correctly
if (location.hash && location.hash !== '#library') {
route(); route();
} }
} }
function route() { function route() {
const hash = location.hash.slice(1) || 'protocols'; const hash = location.hash.slice(1) || 'library';
if (hash.startsWith('protocols/') && hash !== 'protocols') { if (hash.startsWith('protocols/')) {
showDetail(hash.split('/')[1]); showDetail(hash.split('/')[1]);
} else if (hash === 'protocols') {
navigate('library');
} else if (hash.startsWith('users/') || hash.startsWith('user/')) { } else if (hash.startsWith('users/') || hash.startsWith('user/')) {
showUser(hash.split('/')[1]); showUser(hash.split('/')[1]);
} else if (hash.startsWith('collections/') && hash !== 'collections') { } else if (hash.startsWith('collections/')) {
showCollectionDetail(hash.split('/')[1]); showCollectionDetail(hash.split('/')[1]);
} else if (hash === 'collections') {
navigate('collections');
} else if (hash === 'create') { } else if (hash === 'create') {
navigate('create'); navigate('create');
} else if (hash === 'create-collection') { } else if (hash === 'create-collection') {
navigate('create-collection'); navigate('create-collection');
} else if (hash === 'collections') {
navigate('collections');
} else if (hash === 'about') { } else if (hash === 'about') {
navigate('about'); navigate('about');
} else if (hash === 'terms') {
navigate('terms');
} else { } else {
navigate('library'); navigate('library');
} }
@@ -120,7 +87,8 @@ let searchTimer = null;
let currentRoute = ''; let currentRoute = '';
function navigate(view) { function navigate(view) {
if (currentRoute === view && view !== 'library') return; // Prevent double navigation from hashchange
if (currentRoute === view) 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';
@@ -128,18 +96,18 @@ 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 = 'protocols'; loadProtocols(); } if (view === 'library') { location.hash = 'library'; loadProtocols(); }
if (view === 'collections') { location.hash = 'collections'; loadCollections(); } if (view === 'collections') { location.hash = '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'; updateAboutPage(); } if (view === 'about') { location.hash = 'about'; }
if (view === 'terms') { location.hash = 'terms'; }
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
// --- Mobile menu ---
function updateBreadcrumb(view) { function updateBreadcrumb(view) {
const path = document.getElementById('crumbPath'); const path = document.getElementById('crumbPath');
const map = { library: 'protocols/', create: 'protocols/create', collections: 'collections/', 'create-collection': 'collections/create', about: 'about', terms: 'terms' }; const map = { library: '', create: 'create', collections: 'collections/', 'create-collection': 'collections/create', about: 'about' };
path.textContent = map[view] || ''; path.textContent = map[view] || '';
} }
@@ -159,9 +127,9 @@ function renderAuth() {
const area = document.getElementById('authArea'); const area = document.getElementById('authArea');
const mobileAuth = document.getElementById('mobileAuth'); const mobileAuth = document.getElementById('mobileAuth');
if (currentUser) { if (currentUser) {
area.innerHTML = '<a href="#users/' + currentUser.username + '" class="user-link">@' + currentUser.username + '</a>' + area.innerHTML = '<a href="#user/' + currentUser.username + '" class="user-link">@' + currentUser.username + '</a>' +
'<button class="auth-btn" onclick="logout()">Sign Out</button>'; '<button class="auth-btn" onclick="logout()">Sign Out</button>';
mobileAuth.innerHTML = '<a href="#users/' + currentUser.username + '" onclick="closeMenu()">@' + currentUser.username + '</a>' + mobileAuth.innerHTML = '<a href="#user/' + currentUser.username + '" onclick="closeMenu()">@' + currentUser.username + '</a>' +
'<button class="auth-btn" onclick="logout()">Sign Out</button>'; '<button class="auth-btn" onclick="logout()">Sign Out</button>';
} else { } else {
area.innerHTML = '<button class="auth-btn" onclick="openAuthModal(\'login\')">Sign In</button>' + area.innerHTML = '<button class="auth-btn" onclick="openAuthModal(\'login\')">Sign In</button>' +
@@ -224,17 +192,20 @@ function debouncedLoadProtocols() {
async function loadProtocols() { async function loadProtocols() {
const q = document.getElementById('searchInput') ? document.getElementById('searchInput').value : ''; const q = document.getElementById('searchInput') ? document.getElementById('searchInput').value : '';
try { try {
// Fetch all protocols — we do priority-weighted search client-side
allProtocols = await api('/protocols'); allProtocols = await api('/protocols');
const tagSet = new Set(); const tagSet = new Set();
allProtocols.forEach(p => (p.tags || []).forEach(t => tagSet.add(t))); allProtocols.forEach(p => (p.tags || []).forEach(t => tagSet.add(t)));
allTags = [...tagSet].sort(); allTags = [...tagSet].sort();
renderFilters(); renderFilters();
// Filter by tag
let filtered = allProtocols; let filtered = allProtocols;
if (activeFilter !== 'ALL') { if (activeFilter !== 'ALL') {
filtered = filtered.filter(p => (p.tags || []).some(t => t.toLowerCase() === activeFilter.toLowerCase())); filtered = filtered.filter(p => (p.tags || []).some(t => t.toLowerCase() === activeFilter.toLowerCase()));
} }
// Filter + score by search query
if (q.trim()) { if (q.trim()) {
var query = q.toLowerCase().trim(); var query = q.toLowerCase().trim();
var scored = filtered.map(function(p) { var scored = filtered.map(function(p) {
@@ -258,17 +229,13 @@ async function loadProtocols() {
} }
filtered = scored.map(function(item) { return item.p; }); filtered = scored.map(function(item) { return item.p; });
} else { } else {
// No search query — sort by date (newest first)
filtered = filtered.slice().sort(function(a, b) { filtered = filtered.slice().sort(function(a, b) {
return (b.created_at || '').localeCompare(a.created_at || ''); return (b.created_at || '').localeCompare(a.created_at || '');
}); });
} }
var grid = document.getElementById('protocolGrid'); renderProtocolGrid(filtered);
if (!filtered.length) {
grid.innerHTML = '<div class="empty">No protocols found. <a href="#create" onclick="navigate(\'create\');return false">Create one</a></div>';
} else {
grid.innerHTML = filtered.map(renderProtocolPill).join('');
}
renderSortToggle(); renderSortToggle();
} catch (e) { } catch (e) {
document.getElementById('protocolGrid').innerHTML = '<div class="empty">No protocols found. ' + escapeHtml(e.message) + '</div>'; document.getElementById('protocolGrid').innerHTML = '<div class="empty">No protocols found. ' + escapeHtml(e.message) + '</div>';
@@ -277,15 +244,12 @@ async function loadProtocols() {
function setSort(mode) { function setSort(mode) {
sortBy = mode; sortBy = mode;
document.querySelectorAll('.sort-option').forEach(function(el) {
el.classList.toggle('active', el.textContent.trim().startsWith(mode === 'date' ? 'date' : 'relevance'));
});
loadProtocols(); loadProtocols();
} }
function renderSortToggle() { function renderSortToggle() {
document.querySelectorAll('.sort-option').forEach(function(el) { document.querySelectorAll('.sort-option').forEach(function(el, i) {
var isDate = el.textContent.trim().startsWith('date'); var isDate = i === 0;
el.classList.toggle('active', (sortBy === 'date' && isDate) || (sortBy === 'relevance' && !isDate)); el.classList.toggle('active', (sortBy === 'date' && isDate) || (sortBy === 'relevance' && !isDate));
}); });
} }
@@ -298,7 +262,7 @@ function renderFilters() {
if (q) { if (q) {
tags = tags.filter(function(t) { return t.toLowerCase().includes(q); }); tags = tags.filter(function(t) { return t.toLowerCase().includes(q); });
} else { } else {
tags = tags.slice(0, 10); tags = tags.slice(0, 10); // Show top 10 tags by default
} }
tags = ['all', ...tags]; tags = ['all', ...tags];
bar.innerHTML = tags.map(function(t) { bar.innerHTML = tags.map(function(t) {
@@ -323,6 +287,26 @@ function setFilter(tag) {
loadProtocols(); loadProtocols();
} }
function renderProtocolGrid(protocols) {
const grid = document.getElementById('protocolGrid');
if (!protocols.length) {
grid.innerHTML = '<div class="empty">No protocols yet. <a href="#create" onclick="navigate(\'create\');return false">Create one</a></div>';
return;
}
grid.innerHTML = protocols.map(function(p) {
var sourceLabel = p.source || (p.author ? '@' + p.author : '');
var tags = (p.tags || []).map(function(t) { return '<span class="tag">' + escapeHtml(t) + '</span>'; }).join('');
return '<div class="protocol-pill" onclick="showDetail(\'' + p.slug + '\')">' +
'<div class="info"><h3>' + escapeHtml(p.title) + '</h3>' +
'<p>' + escapeHtml(p.description || '') + '</p>' +
(tags ? '<div class="tag-row">' + tags + '</div>' : '') + '</div>' +
'<div class="meta"><div class="steps">' + (p.steps || []).length + ' steps</div>' +
'<div>' + escapeHtml(sourceLabel) + '</div></div></div>';
}).join('');
}
// (iconFor removed — icons are no longer used)
// --- Detail --- // --- Detail ---
async function showDetail(slug) { async function showDetail(slug) {
location.hash = 'protocols/' + slug; location.hash = 'protocols/' + slug;
@@ -349,11 +333,11 @@ function renderDetail(p) {
return '<div class="step"><div class="number">' + String(i+1).padStart(2,'0') + '</div>' + return '<div class="step"><div class="number">' + String(i+1).padStart(2,'0') + '</div>' +
'<div class="content"><h4>' + escapeHtml(s.headline) + '</h4><p>' + escapeHtml(s.description || '') + '</p></div></div>'; '<div class="content"><h4>' + escapeHtml(s.headline) + '</h4><p>' + escapeHtml(s.description || '') + '</p></div></div>';
}).join(''); }).join('');
const forkNotice = (p.forked_from && p.forked_from !== 'null' && p.forked_from !== null) ? '<div class="fork-notice">forked from: <a href="#protocols/' + p.forked_from + '" onclick="showDetail(\'' + p.forked_from + '\');return false">' + escapeHtml(p.forked_from) + '</a></div>' : ''; const forkNotice = p.forked_from ? '<div class="fork-notice">forked from: <a href="#protocol/' + p.forked_from + '" onclick="showDetail(\'' + p.forked_from + '\');return false">' + p.forked_from + '</a></div>' : '';
const sourceLine = (p.source && p.source !== '@' + (p.author || '')) ? 'SOURCE: ' + escapeHtml(p.source) : ''; const sourceLine = p.source ? 'SOURCE: ' + escapeHtml(p.source) : '';
var sourceLink = p.source_url ? ' · <a href="' + escapeHtml(p.source_url) + '" target="_blank">view_original</a>' : ''; var sourceLink = p.source_url ? ' · <a href="' + escapeHtml(p.source_url) + '" target="_blank">view_original</a>' : '';
var authorLine = p.author ? (sourceLine ? ' · ' : '') + 'authored by <a href="#users/' + p.author + '" onclick="showUser(\'' + p.author + '\');return false">@' + escapeHtml(p.author) + '</a>' : ''; var authorLine = p.author ? ' · authored by <a href="#user/' + p.author + '" onclick="showUser(\'' + p.author + '\');return false">@' + escapeHtml(p.author) + '</a>' : '';
var dateLine = p.created_at ? ' · added ' + formatDate(p.created_at) : ''; var dateLine = p.created_at ? ' · ' + formatDate(p.created_at) : '';
return '<div class="detail-header"><div class="tag-row">' + tags + '</div>' + return '<div class="detail-header"><div class="tag-row">' + tags + '</div>' +
'<h1>' + escapeHtml(p.title) + '</h1>' + '<h1>' + escapeHtml(p.title) + '</h1>' +
@@ -422,7 +406,6 @@ async function deleteProtocol(slug) {
try { try {
await api('/protocols/' + slug, { method: 'DELETE' }); await api('/protocols/' + slug, { method: 'DELETE' });
toast('Protocol deleted'); toast('Protocol deleted');
currentRoute = '';
navigate('library'); navigate('library');
} catch (e) { toast(e.message, true); } } catch (e) { toast(e.message, true); }
} }
@@ -574,9 +557,14 @@ async function loadCollections() {
var grid = document.getElementById('collectionsGrid'); var grid = document.getElementById('collectionsGrid');
if (!collections.length) { if (!collections.length) {
grid.innerHTML = '<div class="empty">No collections yet. <a href="#" onclick="navigate(\'create-collection\');return false">Create one</a></div>'; grid.innerHTML = '<div class="empty">No collections yet. <a href="#" onclick="navigate(\'create-collection\');return false">Create one</a></div>';
} else { return;
grid.innerHTML = collections.map(renderCollectionPill).join('');
} }
grid.innerHTML = collections.map(function(c) {
var sourceLabel = c.author ? '@' + c.author : (c.source || '');
return '<div class="protocol-pill collection" onclick="showCollectionDetail(\'' + c.slug + '\')">' +
'<div class="info"><h3>' + escapeHtml(c.title) + '</h3><p>' + escapeHtml(c.description || '') + '</p></div>' +
'<div class="meta"><div class="steps">' + (c.item_count || 0) + ' protocols</div><div>' + escapeHtml(sourceLabel) + '</div></div></div>';
}).join('');
} catch (e) { } catch (e) {
document.getElementById('collectionsGrid').innerHTML = '<div class="empty">' + escapeHtml(e.message) + '</div>'; document.getElementById('collectionsGrid').innerHTML = '<div class="empty">' + escapeHtml(e.message) + '</div>';
} }
@@ -601,6 +589,7 @@ async function loadProtocolPicker(selectedSlugs) {
} catch (e) { } catch (e) {
pickerProtocolList = []; pickerProtocolList = [];
} }
// Don't render the list — it starts empty, user searches to add
} }
function renderPickerList(protocols) { function renderPickerList(protocols) {
@@ -611,11 +600,14 @@ function renderPickerList(protocols) {
} }
list.innerHTML = '<div class="protocol-grid">' + protocols.map(function(p) { list.innerHTML = '<div class="protocol-grid">' + protocols.map(function(p) {
var selected = collectionSelectedProtocols.includes(p.slug); var selected = collectionSelectedProtocols.includes(p.slug);
// Use the shared pill renderer, but add picker-specific classes var sourceLabel = p.source || (p.author ? '@' + p.author : '');
var pill = renderProtocolPill(p); var tags = (p.tags || []).map(function(t) { return '<span class="tag">' + escapeHtml(t) + '</span>'; }).join('');
// Insert picker-pill class and data-slug and onclick override return '<div class="protocol-pill picker-pill' + (selected ? ' selected' : '') + '" data-slug="' + escapeHtml(p.slug) + '" onclick="toggleProtocolSelectClick(\'' + escapeHtml(p.slug) + '\', this)">' +
pill = pill.replace('class="protocol-pill"', 'class="protocol-pill picker-pill' + (selected ? ' selected' : '') + '" data-slug="' + escapeHtml(p.slug) + '" onclick="toggleProtocolSelectClick(\'' + escapeHtml(p.slug) + '\', this)"'); '<div class="info"><h3>' + escapeHtml(p.title) + '</h3>' +
return pill; '<p>' + escapeHtml(p.description || '') + '</p>' +
(tags ? '<div class="tag-row">' + tags + '</div>' : '') + '</div>' +
'<div class="meta"><div class="steps">' + (p.steps || []).length + ' steps</div>' +
'<div>' + escapeHtml(sourceLabel) + '</div></div></div>';
}).join('') + '</div>'; }).join('') + '</div>';
} }
@@ -640,6 +632,7 @@ function renderSelectedList() {
} }
container.innerHTML = '<div class="selected-list-header">Selected protocols (' + collectionSelectedProtocols.length + ')</div>' + container.innerHTML = '<div class="selected-list-header">Selected protocols (' + collectionSelectedProtocols.length + ')</div>' +
collectionSelectedProtocols.map(function(slug, i) { collectionSelectedProtocols.map(function(slug, i) {
// Find protocol in the cached list for display
var p = pickerProtocolList.find(function(proto) { return proto.slug === slug; }); var p = pickerProtocolList.find(function(proto) { return proto.slug === slug; });
var title = p ? escapeHtml(p.title) : escapeHtml(slug); var title = p ? escapeHtml(p.title) : escapeHtml(slug);
return '<div class="selected-item" data-slug="' + escapeHtml(slug) + '">' + return '<div class="selected-item" data-slug="' + escapeHtml(slug) + '">' +
@@ -659,10 +652,12 @@ function moveSelected(i, dir) {
collectionSelectedProtocols[i] = collectionSelectedProtocols[j]; collectionSelectedProtocols[i] = collectionSelectedProtocols[j];
collectionSelectedProtocols[j] = tmp; collectionSelectedProtocols[j] = tmp;
renderSelectedList(); renderSelectedList();
// Update picker highlight if visible
refreshPickerHighlight(); refreshPickerHighlight();
} }
function removeSelected(i) { function removeSelected(i) {
var slug = collectionSelectedProtocols[i];
collectionSelectedProtocols.splice(i, 1); collectionSelectedProtocols.splice(i, 1);
renderSelectedList(); renderSelectedList();
refreshPickerHighlight(); refreshPickerHighlight();
@@ -685,6 +680,8 @@ function filterPickerProtocols() {
renderPickerList(filtered); renderPickerList(filtered);
} }
// (checkbox-based toggle removed — now using toggleProtocolSelectClick)
async function saveCollection(e) { async function saveCollection(e) {
e.preventDefault(); e.preventDefault();
if (!currentUser) { toast('Sign in to create collections', true); openAuthModal('login'); return; } if (!currentUser) { toast('Sign in to create collections', true); openAuthModal('login'); return; }
@@ -728,20 +725,24 @@ async function showCollectionDetail(slug) {
if (item.type === 'protocol') { if (item.type === 'protocol') {
try { try {
var p = await api('/protocols/' + item.slug); var p = await api('/protocols/' + item.slug);
itemsHtml += renderProtocolPill(p); itemsHtml += '<div class="protocol-pill" onclick="showDetail(\'' + p.slug + '\')">' +
'<div class="info"><h3>' + escapeHtml(p.title) + '</h3>' +
'<p>' + escapeHtml(p.description || '') + '</p>' +
(p.tags && p.tags.length ? '<div class="tag-row">' + p.tags.map(function(t) { return '<span class="tag">' + escapeHtml(t) + '</span>'; }).join('') + '</div>' : '') + '</div>' +
'<div class="meta"><div class="steps">' + (p.steps || []).length + ' steps</div></div></div>';
} catch (e2) { } catch (e2) {
itemsHtml += '<div class="protocol-pill"><div class="info"><h3>' + escapeHtml(item.slug) + '</h3><p>Protocol not found</p></div></div>'; itemsHtml += '<div class="protocol-pill"><div class="info"><h3>' + escapeHtml(item.slug) + '</h3><p>Protocol not found</p></div></div>';
} }
} }
} }
var sourceLine = (c.source && c.source !== '@' + (c.author || '')) ? 'SOURCE: ' + escapeHtml(c.source) : ''; var sourceLine = c.source ? 'SOURCE: ' + escapeHtml(c.source) : '';
var authorLine = c.author ? (sourceLine ? ' · ' : '') + 'curated by <a href="#users/' + c.author + '" onclick="showUser(\'' + c.author + '\');return false">@' + escapeHtml(c.author) + '</a>' : ''; var authorLine = c.author ? ' · authored by <a href="#user/' + c.author + '" onclick="showUser(\'' + c.author + '\');return false">@' + escapeHtml(c.author) + '</a>' : '';
var dateLine = c.created_at ? ' · added ' + formatDate(c.created_at) : ''; var dateLine = c.created_at ? ' · ' + formatDate(c.created_at) : '';
document.getElementById('collectionDetailContent').innerHTML = document.getElementById('collectionDetailContent').innerHTML =
'<div class="detail-header"><h1>' + escapeHtml(c.title) + '</h1>' + '<div class="detail-header"><h1>' + escapeHtml(c.title) + '</h1>' +
'<p class="description">' + escapeHtml(c.description || '') + '</p>' + '<p class="description">' + escapeHtml(c.description || '') + '</p>' +
'<div class="source">' + sourceLine + authorLine + dateLine + '</div></div>' + '<div class="source">' + sourceLine + authorLine + dateLine + '</div></div>' +
(itemsHtml ? '<div class="detail-section"><h2>Protocols (' + (c.items || []).length + ')</h2><div class="protocol-grid">' + itemsHtml + '</div></div>' : '<div class="empty">No protocols in this collection yet.</div>'); (itemsHtml ? '<div class="detail-section"><h2>Protocols (' + (c.items || []).length + ')</h2><div class="protocol-grid">' + itemsHtml + '</div></div>' : '<div class="empty">No protocols in this collection yet.</div>');
var canEdit = currentUser && (c.author_id == currentUser.id || currentUser.role === 'admin'); var canEdit = currentUser && (c.author_id == currentUser.id || currentUser.role === 'admin');
@@ -775,7 +776,6 @@ async function deleteCollection(slug) {
try { try {
await api('/collections/' + slug, { method: 'DELETE' }); await api('/collections/' + slug, { method: 'DELETE' });
toast('Collection deleted'); toast('Collection deleted');
currentRoute = '';
navigate('collections'); navigate('collections');
} catch (e) { toast(e.message, true); } } catch (e) { toast(e.message, true); }
} }
@@ -796,38 +796,27 @@ async function showUser(username) {
'<div class="profile-card"><h2>@' + escapeHtml(data.user.username) + '</h2>' + '<div class="profile-card"><h2>@' + escapeHtml(data.user.username) + '</h2>' +
(data.user.display_name ? '<div class="bio">' + escapeHtml(data.user.display_name) + '</div>' : '') + (data.user.display_name ? '<div class="bio">' + escapeHtml(data.user.display_name) + '</div>' : '') +
(data.user.bio ? '<div class="bio">' + escapeHtml(data.user.bio) + '</div>' : '') + '</div>' + (data.user.bio ? '<div class="bio">' + escapeHtml(data.user.bio) + '</div>' : '') + '</div>' +
(p.length ? '<div class="detail-section"><h2>Protocols (' + p.length + ')</h2><div class="protocol-grid">' + (p.length ? '<div class="detail-section"><h2>Protocols (' + p.length + ')</h2>' +
p.map(renderProtocolPill).join('') + p.map(function(proto) {
'</div></div>' : '') + return '<div class="protocol-pill" onclick="showDetail(\'' + proto.slug + '\')">' +
(c.length ? '<div class="detail-section"><h2>Collections (' + c.length + ')</h2><div class="protocol-grid">' + '<div class="info"><h3>' + escapeHtml(proto.title) + '</h3>' +
c.map(renderCollectionPill).join('') + '<p>' + escapeHtml(proto.description||'') + '</p>' +
'</div></div>' : ''); (proto.tags && proto.tags.length ? '<div class="tag-row">' + proto.tags.map(function(t) { return '<span class="tag">' + escapeHtml(t) + '</span>'; }).join('') + '</div>' : '') + '</div>' +
'<div class="meta"><div class="steps">' + (proto.steps || []).length + ' steps</div></div></div>';
}).join('') +
'</div>' : '') +
(c.length ? '<div class="detail-section"><h2>Collections (' + c.length + ')</h2>' +
c.map(function(col) {
return '<div class="protocol-pill collection" onclick="showCollectionDetail(\'' + col.slug + '\')"><div class="icon">[+]</div>' +
'<div class="info"><h3>' + escapeHtml(col.title) + '</h3><p>' + escapeHtml(col.description||'') + '</p></div></div>';
}).join('') +
'</div>' : '');
document.getElementById('detailActions').innerHTML = ''; document.getElementById('detailActions').innerHTML = '';
} 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>';
} }
} }
// --- About page ---
function updateAboutPage() {
var section = document.getElementById('seedSection');
if (!section) return;
section.style.display = (currentUser && currentUser.role === 'admin') ? 'block' : 'none';
}
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 = 'Loaded ' + result.imported + ' protocols'; btn.disabled = true; }
} catch (e) {
toast(e.message, true);
if (btn) { btn.disabled = false; btn.textContent = 'Load seed protocols'; }
}
}
// --- Toast --- // --- Toast ---
let toastTimer = null; let toastTimer = null;
function toast(msg, isError) { function toast(msg, isError) {
@@ -847,6 +836,7 @@ function escapeHtml(s) {
function formatDate(s) { function formatDate(s) {
if (!s) return ''; if (!s) return '';
// Normalize MySQL DATETIME format (space-separated) to ISO
var d = new Date(String(s).replace(' ', 'T')); var d = new Date(String(s).replace(' ', 'T'));
if (isNaN(d)) return s; if (isNaN(d)) return s;
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
@@ -854,4 +844,4 @@ function formatDate(s) {
} }
// --- Boot --- // --- Boot ---
init(); init();
+5 -28
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Protocol Droid</title> <title>Protocol Droid</title>
<link rel="stylesheet" href="frontend/style.css"> <link rel="stylesheet" href="/frontend/style.css">
<meta name="theme-color" content="#0a1929"> <meta name="theme-color" content="#0a1929">
</head> </head>
<body> <body>
@@ -15,7 +15,7 @@
<span class="crumb-path" id="crumbPath"></span> <span class="crumb-path" id="crumbPath"></span>
</div> </div>
<nav id="navBar"> <nav id="navBar">
<a href="#protocols" class="active" data-view="library">PROTOCOLS</a> <a href="#library" class="active" data-view="library">PROTOCOLS</a>
<a href="#collections" data-view="collections">COLLECTIONS</a> <a href="#collections" data-view="collections">COLLECTIONS</a>
<a href="#about" data-view="about">ABOUT</a> <a href="#about" data-view="about">ABOUT</a>
</nav> </nav>
@@ -24,7 +24,7 @@
</header> </header>
<div class="mobile-panel" id="mobilePanel"> <div class="mobile-panel" id="mobilePanel">
<a href="#protocols" data-view="library" class="active" onclick="mobileNav('library')">PROTOCOLS</a> <a href="#library" data-view="library" class="active" onclick="mobileNav('library')">PROTOCOLS</a>
<a href="#collections" data-view="collections" onclick="mobileNav('collections')">COLLECTIONS</a> <a href="#collections" data-view="collections" onclick="mobileNav('collections')">COLLECTIONS</a>
<a href="#about" data-view="about" onclick="mobileNav('about')">ABOUT</a> <a href="#about" data-view="about" onclick="mobileNav('about')">ABOUT</a>
<div class="mobile-auth" id="mobileAuth"></div> <div class="mobile-auth" id="mobileAuth"></div>
@@ -43,7 +43,7 @@
</div> </div>
<div class="sort-bar" id="sortBar"> <div class="sort-bar" id="sortBar">
<span class="sort-label">sort:</span> <span class="sort-label">sort:</span>
<span class="sort-option active" onclick="setSort('date')">date added</span> <span class="sort-option active" onclick="setSort('date')">most recent</span>
<span class="sort-option" onclick="setSort('relevance')">relevance</span> <span class="sort-option" onclick="setSort('relevance')">relevance</span>
<span class="filter-toggle" onclick="toggleFilterPanel()">&#9776; filter</span> <span class="filter-toggle" onclick="toggleFilterPanel()">&#9776; filter</span>
</div> </div>
@@ -87,25 +87,6 @@
<h2>License</h2> <h2>License</h2>
<p class="outcome">Hippocratic License (HL3-CORE) — do no harm. See <a href="https://firstdonoharm.dev/" target="_blank">firstdonoharm.dev</a>. Source code available on <a href="https://git.medlab.host/ntnsndr/protocol-droid" target="_blank">Gitea</a>.</p> <p class="outcome">Hippocratic License (HL3-CORE) — do no harm. See <a href="https://firstdonoharm.dev/" target="_blank">firstdonoharm.dev</a>. Source code available on <a href="https://git.medlab.host/ntnsndr/protocol-droid" target="_blank">Gitea</a>.</p>
</div> </div>
<div class="detail-section" id="seedSection" style="display:none">
<h2>Seed protocols</h2>
<p class="outcome">Load a starter set of 6 example protocols (Round Robin Check-In, Consent Decision-Making, Appreciative Apology, Temperature Reading, Dot Voting, Fishbowl Discussion) into your library.</p>
<div class="actions">
<button class="btn primary" id="seedBtn" onclick="loadSeedProtocols()">Load seed protocols</button>
</div>
</div>
</section>
<!-- TERMS OF SERVICE VIEW -->
<section class="view" id="view-terms" style="display:none">
<div class="detail-header">
<h1>Terms of Service</h1>
</div>
<div class="detail-section">
<p class="outcome">By submitting protocols or collections to this site, you acknowledge that the site administrator reserves the right to remove any submitted content at any time, for any reason, without notice.</p>
<p class="outcome" style="margin-top:12px">Submitted content is licensed by its authors under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank">Creative Commons Attribution 4.0 (CC-BY)</a> unless otherwise specified.</p>
<p class="outcome" style="margin-top:12px">This software itself is licensed under the <a href="https://firstdonoharm.dev/" target="_blank">Hippocratic License (HL3-CORE)</a>.</p>
</div>
</section> </section>
<!-- PROTOCOL DETAIL VIEW --> <!-- PROTOCOL DETAIL VIEW -->
@@ -251,10 +232,6 @@
</main> </main>
<footer class="footer"> <script src="/frontend/app.js"></script>
<span>Protocols licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank">CC-BY 4.0</a> · <a href="#terms" onclick="navigate('terms');return false">Terms of Service</a></span>
</footer>
<script src="frontend/app.js"></script>
</body> </body>
</html> </html>