7e00656feb
- Sort toggle now says 'most recent' instead of 'date added' - Sort logic uses index position instead of text matching - Detail pages show just the date without 'added' prefix
848 lines
35 KiB
JavaScript
848 lines
35 KiB
JavaScript
/* Protocol Droid — Frontend Application */
|
||
|
||
/* Detect base path from the script's own URL for subdirectory deployments */
|
||
/* app.js is loaded from <base>/frontend/app.js, so base = everything before /frontend/app.js */
|
||
(function() {
|
||
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 = '<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;
|
||
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 '<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 ? ' · ' + 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">> 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">></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 ? ' · ' + 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,'&').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();
|