Files
protocol-droid/frontend/app.js
T
Protocolbot 1148c19fd6 feat: shared rendering, footer, terms of service, git deployment, consistency fixes
Rendering consistency:
- Create shared renderProtocolPill() and renderCollectionPill() functions
  used everywhere: library, collection detail, user profile, picker
- All protocol/collection modules now look identical across all contexts
- Profile page collections and protocols use the same shared renderers
- Protocol grids wrapped in .protocol-grid for consistent spacing

New features:
- Footer with CC-BY 4.0 license link and Terms of Service link
- Terms of Service page stating admin reserves right to remove content
- CC-BY 4.0 default license for submitted content

Deployment:
- README now recommends git clone for initial deployment
- README documents git pull for updates without data loss
- All nav links use #protocols instead of #library

Bug fixes (from previous commits, re-applied to current codebase):
- Delete redirect: reset currentRoute guard so navigate works after delete
- #protocols routing instead of #library
- forked_from: null no longer shows as clickable link
- Source field suppressed when it duplicates author
- Collections say 'curated by' instead of 'authored by'
- Breadcrumb home click works from detail pages
- Search debounce and double-navigation guard
2026-07-06 22:40:34 -06:00

857 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Protocol Droid — Frontend Application */
/* Detect base path from the script's own URL for subdirectory deployments */
(function() {
var scripts = document.getElementsByTagName('script');
for (var i = 0; i < scripts.length; i++) {
var src = scripts[i].src || '';
var m = src.match(/^https?:\/\/[^/]+(\/.*)?\/frontend\/app\.js$/);
if (m) {
window.BASE_PATH = m[1] || '';
break;
}
}
})();
const API = (window.BASE_PATH || '') + '/api';
let currentUser = null;
let siteConfig = {};
let allTags = [];
let activeFilter = 'ALL';
let editingSlug = null;
let editingCollectionSlug = null;
let collectionSelectedProtocols = [];
let pickerProtocolList = [];
let currentTags = [];
let allExistingTags = [];
// --- API helpers ---
async function api(path, options = {}) {
const res = await fetch(API + path, {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Request failed');
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 ---
async function init() {
route();
window.addEventListener('hashchange', route);
try {
siteConfig = await api('/config');
var tagline = document.getElementById('libTagline');
if (tagline) tagline.textContent = siteConfig.tagline || '';
document.title = siteConfig.name || 'Protocol Droid';
} catch (e) {}
try {
const { user } = await api('/auth/me');
currentUser = user;
} catch (e) {}
renderAuth();
if (location.hash && location.hash !== '#protocols') {
route();
}
}
function route() {
const hash = location.hash.slice(1) || 'protocols';
if (hash.startsWith('protocols/') && hash !== 'protocols') {
showDetail(hash.split('/')[1]);
} else if (hash === 'protocols') {
navigate('library');
} else if (hash.startsWith('users/') || hash.startsWith('user/')) {
showUser(hash.split('/')[1]);
} else if (hash.startsWith('collections/') && hash !== 'collections') {
showCollectionDetail(hash.split('/')[1]);
} else if (hash === 'collections') {
navigate('collections');
} else if (hash === 'create') {
navigate('create');
} else if (hash === 'create-collection') {
navigate('create-collection');
} else if (hash === 'about') {
navigate('about');
} else if (hash === 'terms') {
navigate('terms');
} else {
navigate('library');
}
}
let searchTimer = null;
let currentRoute = '';
function navigate(view) {
if (currentRoute === view && view !== 'library') return;
currentRoute = view;
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById('view-' + view).style.display = 'block';
document.querySelectorAll('header nav 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();
updateBreadcrumb(view);
if (view === 'library') { location.hash = 'protocols'; loadProtocols(); }
if (view === 'collections') { location.hash = 'collections'; loadCollections(); }
if (view === 'create') { editingSlug = null; resetForm(); }
if (view === 'create-collection') { editingCollectionSlug = null; resetCollectionForm(); }
if (view === 'about') { location.hash = 'about'; updateAboutPage(); }
if (view === 'terms') { location.hash = 'terms'; }
window.scrollTo(0, 0);
}
function updateBreadcrumb(view) {
const path = document.getElementById('crumbPath');
const map = { library: 'protocols/', create: 'protocols/create', collections: 'collections/', 'create-collection': 'collections/create', about: 'about', terms: 'terms' };
path.textContent = map[view] || '';
}
// --- Mobile menu ---
function toggleMenu() {
document.getElementById('mobilePanel').classList.toggle('open');
}
function closeMenu() {
document.getElementById('mobilePanel').classList.remove('open');
}
function mobileNav(view) {
navigate(view);
}
// --- Auth ---
function renderAuth() {
const area = document.getElementById('authArea');
const mobileAuth = document.getElementById('mobileAuth');
if (currentUser) {
area.innerHTML = '<a href="#users/' + currentUser.username + '" class="user-link">@' + currentUser.username + '</a>' +
'<button class="auth-btn" onclick="logout()">Sign Out</button>';
mobileAuth.innerHTML = '<a href="#users/' + currentUser.username + '" onclick="closeMenu()">@' + currentUser.username + '</a>' +
'<button class="auth-btn" onclick="logout()">Sign Out</button>';
} else {
area.innerHTML = '<button class="auth-btn" onclick="openAuthModal(\'login\')">Sign In</button>' +
'<button class="auth-btn" onclick="openAuthModal(\'register\')">Register</button>';
mobileAuth.innerHTML = '<button class="auth-btn" onclick="closeMenu(); openAuthModal(\'login\')">Sign In</button>' +
'<button class="auth-btn" onclick="closeMenu(); openAuthModal(\'register\')">Register</button>';
}
}
let authMode = 'login';
function openAuthModal(mode) {
authMode = mode;
document.getElementById('authModal').style.display = 'flex';
document.getElementById('authModalTitle').textContent = mode === 'register' ? 'Create Account' : 'Sign In';
document.getElementById('authSubmit').textContent = mode === 'register' ? 'Register' : 'Sign In';
document.getElementById('displayNameGroup').style.display = mode === 'register' ? 'block' : 'none';
document.getElementById('authToggle').innerHTML = mode === 'register'
? 'Already have an account? <a onclick="openAuthModal(\'login\')">Sign in</a>'
: 'No account? <a onclick="openAuthModal(\'register\')">Register</a>';
}
function closeAuthModal() { document.getElementById('authModal').style.display = 'none'; }
async function handleAuth(e) {
e.preventDefault();
const username = document.getElementById('authUsername').value;
const password = document.getElementById('authPassword').value;
const displayName = document.getElementById('authDisplayName').value;
try {
let data;
if (authMode === 'register') {
data = await api('/auth/register', { method: 'POST', body: JSON.stringify({ username, password, display_name: displayName }) });
} else {
data = await api('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) });
}
currentUser = data;
renderAuth();
closeAuthModal();
toast('Welcome, @' + data.username + '!');
} catch (err) {
toast(err.message, true);
}
}
async function logout() {
await api('/auth/logout', { method: 'POST' });
currentUser = null;
renderAuth();
toast('Signed out');
}
// --- Protocols ---
let allProtocols = [];
let sortBy = 'date';
function debouncedLoadProtocols() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(loadProtocols, 200);
}
async function loadProtocols() {
const q = document.getElementById('searchInput') ? document.getElementById('searchInput').value : '';
try {
allProtocols = await api('/protocols');
const tagSet = new Set();
allProtocols.forEach(p => (p.tags || []).forEach(t => tagSet.add(t)));
allTags = [...tagSet].sort();
renderFilters();
let filtered = allProtocols;
if (activeFilter !== 'ALL') {
filtered = filtered.filter(p => (p.tags || []).some(t => t.toLowerCase() === activeFilter.toLowerCase()));
}
if (q.trim()) {
var query = q.toLowerCase().trim();
var scored = filtered.map(function(p) {
var score = 0;
if ((p.title || '').toLowerCase().includes(query)) score += 100;
if ((p.tags || []).some(function(t) { return t.toLowerCase().includes(query); })) score += 50;
if ((p.source || '').toLowerCase().includes(query)) score += 30;
if ((p.author || '').toLowerCase().includes(query)) score += 30;
if ((p.description || '').toLowerCase().includes(query)) score += 10;
if ((p.steps || []).some(function(s) {
return (s.headline || '').toLowerCase().includes(query) || (s.description || '').toLowerCase().includes(query);
})) score += 5;
if ((p.outcome || '').toLowerCase().includes(query)) score += 3;
if ((p.practice || '').toLowerCase().includes(query)) score += 3;
return { p: p, score: score };
}).filter(function(item) { return item.score > 0; });
if (sortBy === 'relevance') {
scored.sort(function(a, b) { return b.score - a.score; });
} else {
scored.sort(function(a, b) { return (b.p.created_at || '').localeCompare(a.p.created_at || ''); });
}
filtered = scored.map(function(item) { return item.p; });
} else {
filtered = filtered.slice().sort(function(a, b) {
return (b.created_at || '').localeCompare(a.created_at || '');
});
}
var grid = document.getElementById('protocolGrid');
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();
} catch (e) {
document.getElementById('protocolGrid').innerHTML = '<div class="empty">No protocols found. ' + escapeHtml(e.message) + '</div>';
}
}
function setSort(mode) {
sortBy = mode;
document.querySelectorAll('.sort-option').forEach(function(el) {
el.classList.toggle('active', el.textContent.trim().startsWith(mode === 'date' ? 'date' : 'relevance'));
});
loadProtocols();
}
function renderSortToggle() {
document.querySelectorAll('.sort-option').forEach(function(el) {
var isDate = el.textContent.trim().startsWith('date');
el.classList.toggle('active', (sortBy === 'date' && isDate) || (sortBy === 'relevance' && !isDate));
});
}
function renderFilters() {
const bar = document.getElementById('filterBar');
if (!bar) return;
var q = document.getElementById('tagSearch') ? document.getElementById('tagSearch').value.toLowerCase().trim() : '';
var tags = allTags.slice().sort();
if (q) {
tags = tags.filter(function(t) { return t.toLowerCase().includes(q); });
} else {
tags = tags.slice(0, 10);
}
tags = ['all', ...tags];
bar.innerHTML = tags.map(function(t) {
var display = t === 'all' ? 'all' : t;
return '<span class="filter-pill ' + (t === activeFilter.toLowerCase() ? 'active' : '') + '" onclick="setFilter(\'' + t + '\')">' + escapeHtml(display) + '</span>';
}).join('');
}
function toggleFilterPanel() {
var panel = document.getElementById('filterPanel');
var toggle = document.querySelector('.filter-toggle');
panel.classList.toggle('open');
toggle.classList.toggle('active', panel.classList.contains('open'));
}
function renderFilteredTags() {
renderFilters();
}
function setFilter(tag) {
activeFilter = tag;
loadProtocols();
}
// --- Detail ---
async function showDetail(slug) {
location.hash = 'protocols/' + slug;
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById('view-detail').style.display = 'block';
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.remove('active'));
document.getElementById('crumbPath').textContent = 'protocols/' + slug;
closeMenu();
try {
const p = await api('/protocols/' + slug);
document.getElementById('detailContent').innerHTML = renderDetail(p);
renderDetailActions(p);
} catch (e) {
document.getElementById('detailContent').innerHTML = '<div class="empty">Protocol not found</div>';
document.getElementById('detailActions').innerHTML = '';
}
}
function renderDetail(p) {
const tags = (p.tags || []).map(t => '<span class="tag">' + escapeHtml(t) + '</span>').join('');
const steps = (p.steps || []).map(function(s, i) {
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>';
}).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 sourceLine = (p.source && p.source !== '@' + (p.author || '')) ? 'SOURCE: ' + escapeHtml(p.source) : '';
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 dateLine = p.created_at ? ' · added ' + formatDate(p.created_at) : '';
return '<div class="detail-header"><div class="tag-row">' + tags + '</div>' +
'<h1>' + escapeHtml(p.title) + '</h1>' +
'<p class="description">' + escapeHtml(p.description || '') + '</p>' +
'<div class="source">' + sourceLine + sourceLink + authorLine + dateLine + '</div>' +
forkNotice + '</div>' +
(steps ? '<div class="detail-section"><h2>Steps</h2>' + steps + '</div>' : '') +
(p.outcome ? '<div class="detail-section"><h2>Outcome</h2><p class="outcome">' + escapeHtml(p.outcome) + '</p></div>' : '') +
(p.practice ? '<div class="detail-section"><h2>Practice</h2><p class="practice">' + escapeHtml(p.practice) + '</p></div>' : '');
}
function renderDetailActions(p) {
const actions = document.getElementById('detailActions');
const canEdit = currentUser && (p.author_id == currentUser.id || currentUser.role === 'admin');
let html = '<button class="btn primary" onclick="forkProtocol(\'' + p.slug + '\')">Fork Protocol</button>' +
'<button class="btn" onclick="exportYaml(\'' + p.slug + '\')">Export YAML</button>';
if (canEdit) {
html += '<button class="btn" onclick="editProtocol(\'' + p.slug + '\')">Edit</button>' +
'<button class="btn danger" onclick="deleteProtocol(\'' + p.slug + '\')">Delete</button>';
}
actions.innerHTML = html;
}
function forkProtocol(slug) {
if (!currentUser) { toast('Sign in to fork protocols', true); return; }
api('/protocols/' + slug).then(function(p) {
navigate('create');
editingSlug = null;
document.querySelector('[name=title]').value = p.title + ' (Fork)';
document.querySelector('[name=description]').value = p.description || '';
currentTags = [...(p.tags || [])];
renderTagChips();
loadExistingTags();
document.querySelector('[name=source]').value = '@' + currentUser.username;
document.querySelector('[name=source_url]').value = p.source_url || '';
document.querySelector('[name=outcome]').value = p.outcome || '';
document.querySelector('[name=practice]').value = p.practice || '';
editingSlug = '__fork__' + slug;
document.getElementById('stepsEditor').innerHTML = '';
(p.steps || []).forEach(function(s) { addStep(s.headline, s.description); });
toast('Forking protocol — edit and save to create your version');
});
}
function editProtocol(slug) {
api('/protocols/' + slug).then(function(p) {
navigate('create');
editingSlug = slug;
document.querySelector('[name=title]').value = p.title;
document.querySelector('[name=description]').value = p.description || '';
currentTags = [...(p.tags || [])];
renderTagChips();
loadExistingTags();
document.querySelector('[name=source]').value = p.source || '';
document.querySelector('[name=source_url]').value = p.source_url || '';
document.querySelector('[name=outcome]').value = p.outcome || '';
document.querySelector('[name=practice]').value = p.practice || '';
document.querySelector('[name=visibility]').value = p.visibility || 'public';
document.getElementById('stepsEditor').innerHTML = '';
(p.steps || []).forEach(function(s) { addStep(s.headline, s.description); });
});
}
async function deleteProtocol(slug) {
if (!confirm('Delete this protocol? This cannot be undone.')) return;
try {
await api('/protocols/' + slug, { method: 'DELETE' });
toast('Protocol deleted');
currentRoute = '';
navigate('library');
} catch (e) { toast(e.message, true); }
}
function exportYaml(slug) {
window.open(API + '/protocols/' + slug + '/yaml', '_blank');
}
// --- Tag management ---
async function loadExistingTags() {
try {
var protocols = await api('/protocols');
var tagSet = new Set();
protocols.forEach(function(p) { (p.tags || []).forEach(function(t) { tagSet.add(t); }); });
allExistingTags = [...tagSet].sort();
} catch (e) { allExistingTags = []; }
}
function renderTagChips() {
var container = document.getElementById('tagChips');
container.innerHTML = currentTags.map(function(t, i) {
return '<span class="tag-chip">' + escapeHtml(t) + '<button type="button" class="tag-remove" onclick="removeTag(' + i + ')">×</button></span>';
}).join('');
}
function addTag(tag) {
tag = tag.trim().toLowerCase();
if (!tag || currentTags.includes(tag)) return;
currentTags.push(tag);
renderTagChips();
document.getElementById('tagInput').value = '';
hideTagSuggestions();
}
function removeTag(i) {
currentTags.splice(i, 1);
renderTagChips();
}
function handleTagKeydown(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
var val = e.target.value.trim();
if (val) addTag(val);
} else if (e.key === 'Backspace' && !e.target.value && currentTags.length) {
removeTag(currentTags.length - 1);
}
}
function showTagSuggestions() {
var q = document.getElementById('tagInput').value.trim().toLowerCase();
if (!q) { hideTagSuggestions(); return; }
var matches = allExistingTags.filter(function(t) { return t.includes(q) && !currentTags.includes(t); }).slice(0, 6);
var el = document.getElementById('tagSuggestions');
if (!matches.length) { hideTagSuggestions(); return; }
el.innerHTML = matches.map(function(t) {
return '<div class="tag-suggestion" onclick="addTag(\'' + escapeHtml(t) + '\')">' + escapeHtml(t) + '</div>';
}).join('');
el.style.display = 'block';
}
function hideTagSuggestions() {
document.getElementById('tagSuggestions').style.display = 'none';
}
// --- Create/Edit ---
function resetForm() {
document.getElementById('protocolForm').reset();
document.getElementById('stepsEditor').innerHTML = '';
editingSlug = null;
currentTags = [];
renderTagChips();
loadExistingTags();
}
function addStep(headline, description) {
headline = headline || '';
description = description || '';
const editor = document.getElementById('stepsEditor');
const div = document.createElement('div');
div.className = 'step-editor';
const num = editor.children.length + 1;
div.innerHTML =
'<div class="step-num">&gt; step ' + String(num).padStart(2,'0') + '</div>' +
'<div class="step-field"><label>Headline</label>' +
'<input type="text" class="step-headline" value="' + escapeHtml(headline) + '" placeholder="Step headline"></div>' +
'<div class="step-field"><label>Description</label>' +
'<input type="text" class="step-desc" value="' + escapeHtml(description) + '" placeholder="One-sentence description"></div>' +
'<button class="remove-step" onclick="this.closest(\'.step-editor\').remove(); renumberSteps();">remove step</button>';
editor.appendChild(div);
renumberSteps();
}
function renumberSteps() {
document.querySelectorAll('#stepsEditor .step-editor').forEach(function(el, i) {
el.querySelector('.step-num').textContent = '> step ' + String(i+1).padStart(2,'0');
});
}
async function saveProtocol(e) {
e.preventDefault();
if (!currentUser) { toast('Sign in to create protocols', true); openAuthModal('login'); return; }
const form = e.target;
const forkedFrom = editingSlug && editingSlug.startsWith('__fork__') ? editingSlug.replace('__fork__', '') : null;
const isEdit = editingSlug && !forkedFrom;
const data = {
title: form.title.value,
description: form.description.value,
tags: currentTags.slice(),
source: form.source.value || '@' + currentUser.username,
source_url: form.source_url.value,
outcome: form.outcome.value,
practice: form.practice.value,
visibility: form.visibility.value,
forked_from: forkedFrom,
steps: [...document.querySelectorAll('#stepsEditor .step-editor')].map(function(el) {
return {
headline: el.querySelector('.step-headline').value,
description: el.querySelector('.step-desc').value,
};
}),
};
try {
if (isEdit) {
await api('/protocols/' + editingSlug, { method: 'PUT', body: JSON.stringify(data) });
toast('Protocol updated');
showDetail(editingSlug);
} else {
const created = await api('/protocols', { method: 'POST', body: JSON.stringify(data) });
toast('Protocol created');
showDetail(created.slug);
}
} catch (err) { toast(err.message, true); }
}
// --- Collections ---
async function loadCollections() {
try {
var collections = await api('/collections');
var q = document.getElementById('collectionSearch') ? document.getElementById('collectionSearch').value.toLowerCase().trim() : '';
if (q) {
collections = collections.filter(function(c) {
return (c.title || '').toLowerCase().includes(q) || (c.description || '').toLowerCase().includes(q) || (c.author || '').toLowerCase().includes(q);
});
}
var grid = document.getElementById('collectionsGrid');
if (!collections.length) {
grid.innerHTML = '<div class="empty">No collections yet. <a href="#" onclick="navigate(\'create-collection\');return false">Create one</a></div>';
} else {
grid.innerHTML = collections.map(renderCollectionPill).join('');
}
} catch (e) {
document.getElementById('collectionsGrid').innerHTML = '<div class="empty">' + escapeHtml(e.message) + '</div>';
}
}
function resetCollectionForm() {
document.getElementById('collectionForm').reset();
collectionSelectedProtocols = [];
loadProtocolPicker();
}
async function loadProtocolPicker(selectedSlugs) {
selectedSlugs = selectedSlugs || [];
collectionSelectedProtocols = [...selectedSlugs];
const picker = document.getElementById('collProtocolPicker');
picker.innerHTML =
'<div class="selected-list" id="selectedList"></div>' +
'<div class="search-bar" style="margin-bottom:12px"><span class="prompt">&gt;</span><input type="text" id="pickerSearch" placeholder="search protocols to add..." oninput="filterPickerProtocols()"></div>' +
'<div id="pickerList"><div class="empty">Search for protocols above to add them.</div></div>';
try {
pickerProtocolList = await api('/protocols');
} catch (e) {
pickerProtocolList = [];
}
}
function renderPickerList(protocols) {
const list = document.getElementById('pickerList');
if (!protocols.length) {
list.innerHTML = '<div class="empty">No matching protocols found.</div>';
return;
}
list.innerHTML = '<div class="protocol-grid">' + protocols.map(function(p) {
var selected = collectionSelectedProtocols.includes(p.slug);
// Use the shared pill renderer, but add picker-specific classes
var pill = renderProtocolPill(p);
// Insert picker-pill class and data-slug and onclick override
pill = pill.replace('class="protocol-pill"', 'class="protocol-pill picker-pill' + (selected ? ' selected' : '') + '" data-slug="' + escapeHtml(p.slug) + '" onclick="toggleProtocolSelectClick(\'' + escapeHtml(p.slug) + '\', this)"');
return pill;
}).join('') + '</div>';
}
function toggleProtocolSelectClick(slug, el) {
var i = collectionSelectedProtocols.indexOf(slug);
if (i >= 0) {
collectionSelectedProtocols.splice(i, 1);
el.classList.remove('selected');
} else {
collectionSelectedProtocols.push(slug);
el.classList.add('selected');
}
renderSelectedList();
}
function renderSelectedList() {
var container = document.getElementById('selectedList');
if (!container) return;
if (!collectionSelectedProtocols.length) {
container.innerHTML = '';
return;
}
container.innerHTML = '<div class="selected-list-header">Selected protocols (' + collectionSelectedProtocols.length + ')</div>' +
collectionSelectedProtocols.map(function(slug, i) {
var p = pickerProtocolList.find(function(proto) { return proto.slug === slug; });
var title = p ? escapeHtml(p.title) : escapeHtml(slug);
return '<div class="selected-item" data-slug="' + escapeHtml(slug) + '">' +
'<span class="selected-position">' + (i + 1) + '</span>' +
'<span class="selected-title">' + title + '</span>' +
'<button type="button" class="selected-move" onclick="moveSelected(' + i + ', -1)" title="move up">▲</button>' +
'<button type="button" class="selected-move" onclick="moveSelected(' + i + ', 1)" title="move down">▼</button>' +
'<button type="button" class="selected-remove" onclick="removeSelected(' + i + ')" title="remove">×</button>' +
'</div>';
}).join('');
}
function moveSelected(i, dir) {
var j = i + dir;
if (j < 0 || j >= collectionSelectedProtocols.length) return;
var tmp = collectionSelectedProtocols[i];
collectionSelectedProtocols[i] = collectionSelectedProtocols[j];
collectionSelectedProtocols[j] = tmp;
renderSelectedList();
refreshPickerHighlight();
}
function removeSelected(i) {
collectionSelectedProtocols.splice(i, 1);
renderSelectedList();
refreshPickerHighlight();
}
function refreshPickerHighlight() {
document.querySelectorAll('.picker-pill').forEach(function(el) {
var slug = el.dataset.slug;
if (slug) {
el.classList.toggle('selected', collectionSelectedProtocols.includes(slug));
}
});
}
function filterPickerProtocols() {
var q = document.getElementById('pickerSearch').value.toLowerCase().trim();
var filtered = pickerProtocolList.filter(function(p) {
return !q || p.title.toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q) || (p.tags || []).some(function(t) { return t.toLowerCase().includes(q); });
});
renderPickerList(filtered);
}
async function saveCollection(e) {
e.preventDefault();
if (!currentUser) { toast('Sign in to create collections', true); openAuthModal('login'); return; }
const form = e.target;
var data = {
title: form['coll-title'].value,
description: form['coll-description'].value,
source: form['coll-source'].value || '@' + currentUser.username,
visibility: form['coll-visibility'].value,
items: collectionSelectedProtocols.map(function(slug) { return { type: 'protocol', slug: slug }; }),
};
try {
if (editingCollectionSlug) {
await api('/collections/' + editingCollectionSlug, { method: 'PUT', body: JSON.stringify(data) });
toast('Collection updated');
showCollectionDetail(editingCollectionSlug);
} else {
var created = await api('/collections', { method: 'POST', body: JSON.stringify(data) });
toast('Collection created');
showCollectionDetail(created.slug);
}
} catch (err) { toast(err.message, true); }
}
async function showCollectionDetail(slug) {
location.hash = 'collections/' + slug;
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById('view-collection-detail').style.display = 'block';
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.remove('active'));
document.getElementById('crumbPath').textContent = 'collections/' + slug;
closeMenu();
try {
var c = await api('/collections/' + slug);
var itemsHtml = '';
for (var i = 0; i < (c.items || []).length; i++) {
var item = c.items[i];
if (item.type === 'protocol') {
try {
var p = await api('/protocols/' + item.slug);
itemsHtml += renderProtocolPill(p);
} catch (e2) {
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 authorLine = c.author ? (sourceLine ? ' · ' : '') + 'curated by <a href="#users/' + c.author + '" onclick="showUser(\'' + c.author + '\');return false">@' + escapeHtml(c.author) + '</a>' : '';
var dateLine = c.created_at ? ' · added ' + formatDate(c.created_at) : '';
document.getElementById('collectionDetailContent').innerHTML =
'<div class="detail-header"><h1>' + escapeHtml(c.title) + '</h1>' +
'<p class="description">' + escapeHtml(c.description || '') + '</p>' +
'<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>');
var canEdit = currentUser && (c.author_id == currentUser.id || currentUser.role === 'admin');
var actionsHtml = '';
if (canEdit) {
actionsHtml += '<button class="btn" onclick="editCollection(\'' + c.slug + '\')">Edit</button>' +
'<button class="btn danger" onclick="deleteCollection(\'' + c.slug + '\')">Delete</button>';
}
document.getElementById('collectionDetailActions').innerHTML = actionsHtml;
} catch (e) {
document.getElementById('collectionDetailContent').innerHTML = '<div class="empty">Collection not found</div>';
document.getElementById('collectionDetailActions').innerHTML = '';
}
}
function editCollection(slug) {
api('/collections/' + slug).then(function(c) {
navigate('create-collection');
editingCollectionSlug = slug;
document.querySelector('[name=coll-title]').value = c.title;
document.querySelector('[name=coll-description]').value = c.description || '';
document.querySelector('[name=coll-source]').value = c.source || '';
document.querySelector('[name=coll-visibility]').value = c.visibility || 'public';
var selectedSlugs = (c.items || []).filter(function(i) { return i.type === 'protocol'; }).map(function(i) { return i.slug; });
loadProtocolPicker(selectedSlugs).then(function() { renderSelectedList(); });
});
}
async function deleteCollection(slug) {
if (!confirm('Delete this collection? This cannot be undone.')) return;
try {
await api('/collections/' + slug, { method: 'DELETE' });
toast('Collection deleted');
currentRoute = '';
navigate('collections');
} catch (e) { toast(e.message, true); }
}
// --- User Profile ---
async function showUser(username) {
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
document.getElementById('view-detail').style.display = 'block';
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.remove('active'));
document.getElementById('crumbPath').textContent = 'users/' + username;
closeMenu();
try {
const data = await api('/users/' + username);
const p = data.protocols || [];
const c = data.collections || [];
document.getElementById('detailContent').innerHTML =
'<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.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.map(renderProtocolPill).join('') +
'</div></div>' : '') +
(c.length ? '<div class="detail-section"><h2>Collections (' + c.length + ')</h2><div class="protocol-grid">' +
c.map(renderCollectionPill).join('') +
'</div></div>' : '');
document.getElementById('detailActions').innerHTML = '';
} catch (e) {
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 ---
let toastTimer = null;
function toast(msg, isError) {
const el = document.getElementById('toast');
el.textContent = msg;
el.style.display = 'block';
el.style.borderColor = isError ? '#c0392b' : 'var(--accent-bright)';
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function() { el.style.display = 'none'; }, 3000);
}
// --- Util ---
function escapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function formatDate(s) {
if (!s) return '';
var d = new Date(String(s).replace(' ', 'T'));
if (isNaN(d)) return s;
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return months[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear();
}
// --- Boot ---
init();