Files
protocol-droid/frontend/app.js
T
Protocolbot 02794e565e feat: initial release of Protocol Droid v0.2.0
A tool for authoring, sharing, and curating social protocols.

Features:
- Protocol library with search, tag filtering, and sort by date/relevance
- Protocol authoring with structured fields (title, description, steps, outcome, practice, source, tags)
- Protocol forking with provenance tracking
- Collection creation with searchable protocol picker and ordering
- User accounts with roles (admin, member, viewer)
- YAML import/export for portability
- Self-hostable on LAMP/Cloudron, works in subdirectories
- Responsive design with hamburger menu on mobile
- About page

Security:
- CSRF protection via Origin/Referer validation
- Session regeneration on login/register
- Secure session cookie params (HttpOnly, SameSite, Secure)
- Visibility enforcement on private/unlisted items
- YAML object injection hardening
- Login rate limiting
- Path traversal protection
- Input validation and length clamping
- Vote value constraining

Stack: PHP 8.x + MySQL/MariaDB, vanilla JS frontend, no external dependencies.

Hippocratic License (HL3-CORE).
2026-07-06 13:18:08 -06:00

838 lines
35 KiB
JavaScript
Executable File
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 */
const API = '/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;
}
// --- Init ---
async function init() {
// Route immediately — don't wait for API calls
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();
// Re-route after auth loads so auth-dependent UI renders correctly
if (location.hash && location.hash !== '#library') {
route();
}
}
function route() {
const hash = location.hash.slice(1) || 'library';
if (hash.startsWith('protocols/')) {
showDetail(hash.split('/')[1]);
} else if (hash.startsWith('users/') || hash.startsWith('user/')) {
showUser(hash.split('/')[1]);
} else if (hash.startsWith('collections/')) {
showCollectionDetail(hash.split('/')[1]);
} else if (hash === 'create') {
navigate('create');
} else if (hash === 'create-collection') {
navigate('create-collection');
} else if (hash === 'collections') {
navigate('collections');
} else if (hash === 'about') {
navigate('about');
} else {
navigate('library');
}
}
let searchTimer = null;
let currentRoute = '';
function navigate(view) {
// Prevent double navigation from hashchange
if (currentRoute === view) 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 = 'library'; 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'; }
window.scrollTo(0, 0);
}
// --- Mobile menu ---
function updateBreadcrumb(view) {
const path = document.getElementById('crumbPath');
const map = { library: '', create: 'create', collections: 'collections/', 'create-collection': 'collections/create', about: 'about' };
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="#user/' + currentUser.username + '" class="user-link">@' + currentUser.username + '</a>' +
'<button class="auth-btn" onclick="logout()">Sign Out</button>';
mobileAuth.innerHTML = '<a href="#user/' + 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 {
// Fetch all protocols — we do priority-weighted search client-side
allProtocols = await api('/protocols');
const tagSet = new Set();
allProtocols.forEach(p => (p.tags || []).forEach(t => tagSet.add(t)));
allTags = [...tagSet].sort();
renderFilters();
// Filter by tag
let filtered = allProtocols;
if (activeFilter !== 'ALL') {
filtered = filtered.filter(p => (p.tags || []).some(t => t.toLowerCase() === activeFilter.toLowerCase()));
}
// Filter + score by search query
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 {
// No search query — sort by date (newest first)
filtered = filtered.slice().sort(function(a, b) {
return (b.created_at || '').localeCompare(a.created_at || '');
});
}
renderProtocolGrid(filtered);
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() {
// Update active state based on current sortBy
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); // Show top 10 tags by default
}
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();
}
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 ---
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 ? '<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 ? 'SOURCE: ' + escapeHtml(p.source) : '';
var sourceLink = p.source_url ? ' · <a href="' + escapeHtml(p.source_url) + '" target="_blank">view_original</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) : '';
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');
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>';
return;
}
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) {
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 = [];
}
// Don't render the list — it starts empty, user searches to add
}
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);
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 picker-pill' + (selected ? ' selected' : '') + '" data-slug="' + escapeHtml(p.slug) + '" onclick="toggleProtocolSelectClick(\'' + escapeHtml(p.slug) + '\', this)">' +
'<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('') + '</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) {
// Find protocol in the cached list for display
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();
// Update picker highlight if visible
refreshPickerHighlight();
}
function removeSelected(i) {
var slug = collectionSelectedProtocols[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);
}
// (checkbox-based toggle removed — now using toggleProtocolSelectClick)
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 += '<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) {
itemsHtml += '<div class="protocol-pill"><div class="info"><h3>' + escapeHtml(item.slug) + '</h3><p>Protocol not found</p></div></div>';
}
}
}
var sourceLine = c.source ? 'SOURCE: ' + escapeHtml(c.source) : '';
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) : '';
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');
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>' +
p.map(function(proto) {
return '<div class="protocol-pill" onclick="showDetail(\'' + proto.slug + '\')">' +
'<div class="info"><h3>' + escapeHtml(proto.title) + '</h3>' +
'<p>' + escapeHtml(proto.description||'') + '</p>' +
(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 = '';
} catch (e) {
document.getElementById('detailContent').innerHTML = '<div class="empty">User not found</div>';
}
}
// --- 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 '';
// Normalize MySQL DATETIME format (space-separated) to ISO
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();