/* Protocol Droid — Frontend Application */
/* Detect base path from the script's own URL for subdirectory deployments */
/* app.js is loaded from /frontend/app.js, so base = everything before /frontend/app.js */
(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;
}
// --- 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 = '@' + currentUser.username + ' ' +
'Sign Out ';
mobileAuth.innerHTML = '@' + currentUser.username + ' ' +
'Sign Out ';
} else {
area.innerHTML = 'Sign In ' +
'Register ';
mobileAuth.innerHTML = 'Sign In ' +
'Register ';
}
}
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? Sign in '
: 'No account? Register ';
}
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 = '
No protocols found. ' + escapeHtml(e.message) + '
';
}
}
function setSort(mode) {
sortBy = mode;
loadProtocols();
}
function renderSortToggle() {
document.querySelectorAll('.sort-option').forEach(function(el, i) {
var isDate = i === 0;
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 '' + escapeHtml(display) + ' ';
}).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 = '';
return;
}
grid.innerHTML = protocols.map(function(p) {
var sourceLabel = p.source || (p.author ? '@' + p.author : '');
var tags = (p.tags || []).map(function(t) { return '' + escapeHtml(t) + ' '; }).join('');
return '' +
'
' + escapeHtml(p.title) + ' ' +
'
' + escapeHtml(p.description || '') + '
' +
(tags ? '
' + tags + '
' : '') + '
' +
'
';
}).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 = 'Protocol not found
';
document.getElementById('detailActions').innerHTML = '';
}
}
function renderDetail(p) {
const tags = (p.tags || []).map(t => '' + escapeHtml(t) + ' ').join('');
const steps = (p.steps || []).map(function(s, i) {
return '' + String(i+1).padStart(2,'0') + '
' +
'
' + escapeHtml(s.headline) + ' ' + escapeHtml(s.description || '') + '
';
}).join('');
const forkNotice = p.forked_from ? '' : '';
const sourceLine = p.source ? 'SOURCE: ' + escapeHtml(p.source) : '';
var sourceLink = p.source_url ? ' · view_original ' : '';
var authorLine = p.author ? ' · authored by @' + escapeHtml(p.author) + ' ' : '';
var dateLine = p.created_at ? ' · ' + formatDate(p.created_at) : '';
return '' +
(steps ? '
Steps ' + steps + '' : '') +
(p.outcome ? 'Outcome ' + escapeHtml(p.outcome) + '
' : '') +
(p.practice ? 'Practice ' + escapeHtml(p.practice) + '
' : '');
}
function renderDetailActions(p) {
const actions = document.getElementById('detailActions');
const canEdit = currentUser && (p.author_id == currentUser.id || currentUser.role === 'admin');
let html = 'Fork Protocol ' +
'Export YAML ';
if (canEdit) {
html += 'Edit ' +
'Delete ';
}
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 '' + escapeHtml(t) + '× ';
}).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 '' + escapeHtml(t) + '
';
}).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 =
'> step ' + String(num).padStart(2,'0') + '
' +
'Headline ' +
'
' +
'Description ' +
'
' +
'remove step ';
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 = '';
return;
}
grid.innerHTML = collections.map(function(c) {
var sourceLabel = c.author ? '@' + c.author : (c.source || '');
return '' +
'
' + escapeHtml(c.title) + ' ' + escapeHtml(c.description || '') + '
' +
'
';
}).join('');
} catch (e) {
document.getElementById('collectionsGrid').innerHTML = '' + escapeHtml(e.message) + '
';
}
}
function resetCollectionForm() {
document.getElementById('collectionForm').reset();
collectionSelectedProtocols = [];
loadProtocolPicker();
}
async function loadProtocolPicker(selectedSlugs) {
selectedSlugs = selectedSlugs || [];
collectionSelectedProtocols = [...selectedSlugs];
const picker = document.getElementById('collProtocolPicker');
picker.innerHTML =
'
' +
'>
' +
'Search for protocols above to add them.
';
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 = 'No matching protocols found.
';
return;
}
list.innerHTML = '' + 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 '
' + escapeHtml(t) + ' '; }).join('');
return '
' +
'
' + escapeHtml(p.title) + ' ' +
'
' + escapeHtml(p.description || '') + '
' +
(tags ? '
' + tags + '
' : '') + '
' +
'
';
}).join('') + '
';
}
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 = '' +
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 '' +
'' + (i + 1) + ' ' +
'' + title + ' ' +
'▲ ' +
'▼ ' +
'× ' +
'
';
}).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 += '' +
'
' + escapeHtml(p.title) + ' ' +
'
' + escapeHtml(p.description || '') + '
' +
(p.tags && p.tags.length ? '
' + p.tags.map(function(t) { return '' + escapeHtml(t) + ' '; }).join('') + '
' : '') + '
' +
'
';
} catch (e2) {
itemsHtml += '' + escapeHtml(item.slug) + ' Protocol not found
';
}
}
}
var sourceLine = c.source ? 'SOURCE: ' + escapeHtml(c.source) : '';
var authorLine = c.author ? ' · authored by @' + escapeHtml(c.author) + ' ' : '';
var dateLine = c.created_at ? ' · ' + formatDate(c.created_at) : '';
document.getElementById('collectionDetailContent').innerHTML =
'' +
(itemsHtml ? 'Protocols (' + (c.items || []).length + ') ' + itemsHtml + '
' : 'No protocols in this collection yet.
');
var canEdit = currentUser && (c.author_id == currentUser.id || currentUser.role === 'admin');
var actionsHtml = '';
if (canEdit) {
actionsHtml += 'Edit ' +
'Delete ';
}
document.getElementById('collectionDetailActions').innerHTML = actionsHtml;
} catch (e) {
document.getElementById('collectionDetailContent').innerHTML = 'Collection not found
';
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 =
'@' + escapeHtml(data.user.username) + ' ' +
(data.user.display_name ? '
' + escapeHtml(data.user.display_name) + '
' : '') +
(data.user.bio ? '
' + escapeHtml(data.user.bio) + '
' : '') + '
' +
(p.length ? 'Protocols (' + p.length + ') ' +
p.map(function(proto) {
return '
' +
'
' + escapeHtml(proto.title) + ' ' +
'
' + escapeHtml(proto.description||'') + '
' +
(proto.tags && proto.tags.length ? '
' + proto.tags.map(function(t) { return '' + escapeHtml(t) + ' '; }).join('') + '
' : '') + '
' +
'
';
}).join('') +
'
' : '') +
(c.length ? 'Collections (' + c.length + ') ' +
c.map(function(col) {
return '
[+]
' +
'
' + escapeHtml(col.title) + ' ' + escapeHtml(col.description||'') + '
';
}).join('') +
'
' : '');
document.getElementById('detailActions').innerHTML = '';
} catch (e) {
document.getElementById('detailContent').innerHTML = 'User not found
';
}
}
// --- 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,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
}
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();