/* 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) || 'protocols'; if (hash.startsWith('protocols/') && hash !== 'protocols') { showDetail(hash.split('/')[1]); } else if (hash === 'protocols') { navigate('library'); } else if (hash.startsWith('users/') || hash.startsWith('user/')) { showUser(hash.split('/')[1]); } else if (hash.startsWith('collections/') && hash !== 'collections') { showCollectionDetail(hash.split('/')[1]); } else if (hash === 'collections') { navigate('collections'); } else if (hash === 'create') { navigate('create'); } else if (hash === 'create-collection') { navigate('create-collection'); } else if (hash === 'about') { navigate('about'); } else { navigate('library'); } } let searchTimer = null; let currentRoute = ''; function navigate(view) { // Prevent double navigation from hashchange (but allow explicit resets) if (currentRoute === view && view !== 'library') return; currentRoute = view; document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); document.getElementById('view-' + view).style.display = 'block'; document.querySelectorAll('header nav a').forEach(a => a.classList.toggle('active', a.dataset.view === view)); document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.toggle('active', a.dataset.view === view)); closeMenu(); updateBreadcrumb(view); if (view === 'library') { location.hash = 'protocols'; loadProtocols(); } if (view === 'collections') { location.hash = 'collections'; loadCollections(); } if (view === 'create') { editingSlug = null; resetForm(); } if (view === 'create-collection') { editingCollectionSlug = null; resetCollectionForm(); } if (view === 'about') { location.hash = 'about'; } window.scrollTo(0, 0); } // --- Mobile menu --- function updateBreadcrumb(view) { const path = document.getElementById('crumbPath'); const map = { library: 'protocols/', create: 'protocols/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 + '' + ''; mobileAuth.innerHTML = '@' + currentUser.username + '' + ''; } else { area.innerHTML = '' + ''; mobileAuth.innerHTML = '' + ''; } } 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; 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 '' + 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 = '
No protocols yet. Create one
'; 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 + '
' : '') + '
' + '
' + (p.steps || []).length + ' steps
' + '
' + escapeHtml(sourceLabel) + '
'; }).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 && p.forked_from !== 'null') ? '
forked from: ' + 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 ? ' · added ' + formatDate(p.created_at) : ''; return '
' + tags + '
' + '

' + escapeHtml(p.title) + '

' + '

' + escapeHtml(p.description || '') + '

' + '
' + sourceLine + sourceLink + authorLine + dateLine + '
' + forkNotice + '
' + (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 = '' + ''; if (canEdit) { html += '' + ''; } actions.innerHTML = html; } function forkProtocol(slug) { if (!currentUser) { toast('Sign in to fork protocols', true); return; } api('/protocols/' + slug).then(function(p) { navigate('create'); editingSlug = null; document.querySelector('[name=title]').value = p.title + ' (Fork)'; document.querySelector('[name=description]').value = p.description || ''; currentTags = [...(p.tags || [])]; renderTagChips(); loadExistingTags(); document.querySelector('[name=source]').value = '@' + currentUser.username; document.querySelector('[name=source_url]').value = p.source_url || ''; document.querySelector('[name=outcome]').value = p.outcome || ''; document.querySelector('[name=practice]').value = p.practice || ''; editingSlug = '__fork__' + slug; document.getElementById('stepsEditor').innerHTML = ''; (p.steps || []).forEach(function(s) { addStep(s.headline, s.description); }); toast('Forking protocol — edit and save to create your version'); }); } function editProtocol(slug) { api('/protocols/' + slug).then(function(p) { navigate('create'); editingSlug = slug; document.querySelector('[name=title]').value = p.title; document.querySelector('[name=description]').value = p.description || ''; currentTags = [...(p.tags || [])]; renderTagChips(); loadExistingTags(); document.querySelector('[name=source]').value = p.source || ''; document.querySelector('[name=source_url]').value = p.source_url || ''; document.querySelector('[name=outcome]').value = p.outcome || ''; document.querySelector('[name=practice]').value = p.practice || ''; document.querySelector('[name=visibility]').value = p.visibility || 'public'; document.getElementById('stepsEditor').innerHTML = ''; (p.steps || []).forEach(function(s) { addStep(s.headline, s.description); }); }); } async function deleteProtocol(slug) { if (!confirm('Delete this protocol? This cannot be undone.')) return; try { await api('/protocols/' + slug, { method: 'DELETE' }); toast('Protocol deleted'); currentRoute = ''; // Reset so navigate works 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') + '
' + '
' + '
' + '
' + '
' + ''; 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 = '
No collections yet. Create one
'; return; } grid.innerHTML = collections.map(function(c) { var sourceLabel = c.author ? '@' + c.author : (c.source || ''); return '
' + '

' + escapeHtml(c.title) + '

' + escapeHtml(c.description || '') + '

' + '
' + (c.item_count || 0) + ' protocols
' + escapeHtml(sourceLabel) + '
'; }).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 + '
' : '') + '
' + '
' + (p.steps || []).length + ' steps
' + '
' + escapeHtml(sourceLabel) + '
'; }).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 = '
Selected protocols (' + collectionSelectedProtocols.length + ')
' + 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('') + '
' : '') + '
' + '
' + (p.steps || []).length + ' steps
'; } 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 ? ' · added ' + formatDate(c.created_at) : ''; document.getElementById('collectionDetailContent').innerHTML = '

' + escapeHtml(c.title) + '

' + '

' + escapeHtml(c.description || '') + '

' + '
' + sourceLine + authorLine + dateLine + '
' + (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 += '' + ''; } 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'); currentRoute = ''; navigate('collections'); } catch (e) { toast(e.message, true); } } // --- User Profile --- async function showUser(username) { document.querySelectorAll('.view').forEach(v => v.style.display = 'none'); document.getElementById('view-detail').style.display = 'block'; document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active')); document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.remove('active')); document.getElementById('crumbPath').textContent = 'users/' + username; closeMenu(); try { const data = await api('/users/' + username); const p = data.protocols || []; const c = data.collections || []; document.getElementById('detailContent').innerHTML = '

@' + 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('') + '
' : '') + '
' + '
' + (proto.steps || []).length + ' steps
'; }).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();