diff --git a/frontend/app.js b/frontend/app.js index 3a01444..c07f4a4 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,6 +1,7 @@ /* 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++) { @@ -36,40 +37,9 @@ async function api(path, options = {}) { return data; } -// --- Shared rendering functions (use everywhere to ensure consistency) --- - -function renderProtocolPill(p) { - var sourceLabel = ''; - if (p.source && p.source !== '@' + (p.author || '')) { - sourceLabel = p.source; - } else if (p.author) { - sourceLabel = '@' + p.author; - } - var tags = (p.tags || []).map(function(t) { return '' + escapeHtml(t) + ''; }).join(''); - return '
' + - '

' + escapeHtml(p.title) + '

' + - '

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

' + - (tags ? '
' + tags + '
' : '') + '
' + - '
' + (p.steps || []).length + ' steps
' + - (sourceLabel ? '
' + escapeHtml(sourceLabel) + '
' : '') + '
'; -} - -function renderCollectionPill(c) { - var sourceLabel = ''; - if (c.source && c.source !== '@' + (c.author || '')) { - sourceLabel = c.source; - } else if (c.author) { - sourceLabel = '@' + c.author; - } - return '
' + - '

' + escapeHtml(c.title) + '

' + - '

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

' + - '
' + (c.item_count || 0) + ' protocols
' + - (sourceLabel ? '
' + escapeHtml(sourceLabel) + '
' : '') + '
'; -} - // --- Init --- async function init() { + // Route immediately — don't wait for API calls route(); window.addEventListener('hashchange', route); @@ -86,31 +56,28 @@ async function init() { } catch (e) {} renderAuth(); - if (location.hash && location.hash !== '#protocols') { + // 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') { + const hash = location.hash.slice(1) || 'library'; + if (hash.startsWith('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') { + } else if (hash.startsWith('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 === 'collections') { + navigate('collections'); } else if (hash === 'about') { navigate('about'); - } else if (hash === 'terms') { - navigate('terms'); } else { navigate('library'); } @@ -120,7 +87,8 @@ let searchTimer = null; let currentRoute = ''; function navigate(view) { - if (currentRoute === view && view !== 'library') return; + // 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'; @@ -128,18 +96,18 @@ function navigate(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 === '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'; updateAboutPage(); } - if (view === 'terms') { location.hash = 'terms'; } + 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', terms: 'terms' }; + const map = { library: '', create: 'create', collections: 'collections/', 'create-collection': 'collections/create', about: 'about' }; path.textContent = map[view] || ''; } @@ -159,9 +127,9 @@ function renderAuth() { const area = document.getElementById('authArea'); const mobileAuth = document.getElementById('mobileAuth'); if (currentUser) { - area.innerHTML = '@' + currentUser.username + '' + + area.innerHTML = '@' + currentUser.username + '' + ''; - mobileAuth.innerHTML = '@' + currentUser.username + '' + + mobileAuth.innerHTML = '@' + currentUser.username + '' + ''; } else { area.innerHTML = '' + @@ -224,17 +192,20 @@ function debouncedLoadProtocols() { 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) { @@ -258,17 +229,13 @@ async function loadProtocols() { } 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 || ''); }); } - var grid = document.getElementById('protocolGrid'); - if (!filtered.length) { - grid.innerHTML = '
No protocols found. Create one
'; - } else { - grid.innerHTML = filtered.map(renderProtocolPill).join(''); - } + renderProtocolGrid(filtered); renderSortToggle(); } catch (e) { document.getElementById('protocolGrid').innerHTML = '
No protocols found. ' + escapeHtml(e.message) + '
'; @@ -277,15 +244,12 @@ async function loadProtocols() { function setSort(mode) { sortBy = mode; - document.querySelectorAll('.sort-option').forEach(function(el) { - el.classList.toggle('active', el.textContent.trim().startsWith(mode === 'date' ? 'date' : 'relevance')); - }); loadProtocols(); } function renderSortToggle() { - document.querySelectorAll('.sort-option').forEach(function(el) { - var isDate = el.textContent.trim().startsWith('date'); + document.querySelectorAll('.sort-option').forEach(function(el, i) { + var isDate = i === 0; el.classList.toggle('active', (sortBy === 'date' && isDate) || (sortBy === 'relevance' && !isDate)); }); } @@ -298,7 +262,7 @@ function renderFilters() { if (q) { tags = tags.filter(function(t) { return t.toLowerCase().includes(q); }); } else { - tags = tags.slice(0, 10); + tags = tags.slice(0, 10); // Show top 10 tags by default } tags = ['all', ...tags]; bar.innerHTML = tags.map(function(t) { @@ -323,6 +287,26 @@ function setFilter(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; @@ -349,11 +333,11 @@ function renderDetail(p) { return '
' + String(i+1).padStart(2,'0') + '
' + '

' + escapeHtml(s.headline) + '

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

'; }).join(''); - const forkNotice = (p.forked_from && p.forked_from !== 'null' && p.forked_from !== null) ? '
forked from: ' + escapeHtml(p.forked_from) + '
' : ''; - const sourceLine = (p.source && p.source !== '@' + (p.author || '')) ? 'SOURCE: ' + escapeHtml(p.source) : ''; + const forkNotice = p.forked_from ? '
forked from: ' + p.forked_from + '
' : ''; + const sourceLine = p.source ? 'SOURCE: ' + escapeHtml(p.source) : ''; var sourceLink = p.source_url ? ' · view_original' : ''; - var authorLine = p.author ? (sourceLine ? ' · ' : '') + 'authored by @' + escapeHtml(p.author) + '' : ''; - var dateLine = p.created_at ? ' · added ' + formatDate(p.created_at) : ''; + var authorLine = p.author ? ' · authored by @' + escapeHtml(p.author) + '' : ''; + var dateLine = p.created_at ? ' · ' + formatDate(p.created_at) : ''; return '
' + tags + '
' + '

' + escapeHtml(p.title) + '

' + @@ -422,7 +406,6 @@ async function deleteProtocol(slug) { try { await api('/protocols/' + slug, { method: 'DELETE' }); toast('Protocol deleted'); - currentRoute = ''; navigate('library'); } catch (e) { toast(e.message, true); } } @@ -574,9 +557,14 @@ async function loadCollections() { var grid = document.getElementById('collectionsGrid'); if (!collections.length) { grid.innerHTML = '
No collections yet. Create one
'; - } else { - grid.innerHTML = collections.map(renderCollectionPill).join(''); + 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) + '
'; } @@ -601,6 +589,7 @@ async function loadProtocolPicker(selectedSlugs) { } catch (e) { pickerProtocolList = []; } + // Don't render the list — it starts empty, user searches to add } function renderPickerList(protocols) { @@ -611,11 +600,14 @@ function renderPickerList(protocols) { } list.innerHTML = '
' + protocols.map(function(p) { var selected = collectionSelectedProtocols.includes(p.slug); - // Use the shared pill renderer, but add picker-specific classes - var pill = renderProtocolPill(p); - // Insert picker-pill class and data-slug and onclick override - pill = pill.replace('class="protocol-pill"', 'class="protocol-pill picker-pill' + (selected ? ' selected' : '') + '" data-slug="' + escapeHtml(p.slug) + '" onclick="toggleProtocolSelectClick(\'' + escapeHtml(p.slug) + '\', this)"'); - return pill; + 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('') + '
'; } @@ -640,6 +632,7 @@ function renderSelectedList() { } 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 '
' + @@ -659,10 +652,12 @@ function moveSelected(i, dir) { 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(); @@ -685,6 +680,8 @@ function filterPickerProtocols() { 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; } @@ -728,20 +725,24 @@ async function showCollectionDetail(slug) { if (item.type === 'protocol') { try { var p = await api('/protocols/' + item.slug); - itemsHtml += renderProtocolPill(p); + 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 && c.source !== '@' + (c.author || '')) ? 'SOURCE: ' + escapeHtml(c.source) : ''; - var authorLine = c.author ? (sourceLine ? ' · ' : '') + 'curated by @' + escapeHtml(c.author) + '' : ''; - var dateLine = c.created_at ? ' · added ' + formatDate(c.created_at) : ''; + 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 = - '

' + escapeHtml(c.title) + '

' + - '

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

' + - '
' + sourceLine + authorLine + dateLine + '
' + + 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'); @@ -775,7 +776,6 @@ async function deleteCollection(slug) { try { await api('/collections/' + slug, { method: 'DELETE' }); toast('Collection deleted'); - currentRoute = ''; navigate('collections'); } catch (e) { toast(e.message, true); } } @@ -796,38 +796,27 @@ async function showUser(username) { '

@' + 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(renderProtocolPill).join('') + - '
' : '') + - (c.length ? '

Collections (' + c.length + ')

' + - c.map(renderCollectionPill).join('') + - '
' : ''); + (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
'; } } -// --- About page --- -function updateAboutPage() { - var section = document.getElementById('seedSection'); - if (!section) return; - section.style.display = (currentUser && currentUser.role === 'admin') ? 'block' : 'none'; -} - -async function loadSeedProtocols() { - var btn = document.getElementById('seedBtn'); - if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } - try { - var result = await api('/seed', { method: 'POST' }); - toast('Loaded ' + result.imported + ' seed protocols'); - if (btn) { btn.textContent = 'Loaded ' + result.imported + ' protocols'; btn.disabled = true; } - } catch (e) { - toast(e.message, true); - if (btn) { btn.disabled = false; btn.textContent = 'Load seed protocols'; } - } -} - // --- Toast --- let toastTimer = null; function toast(msg, isError) { @@ -847,6 +836,7 @@ function escapeHtml(s) { 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']; @@ -854,4 +844,4 @@ function formatDate(s) { } // --- Boot --- -init(); \ No newline at end of file +init(); diff --git a/frontend/index.html b/frontend/index.html index 1ca2c7a..a991a94 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ Protocol Droid - + @@ -15,7 +15,7 @@
@@ -24,7 +24,7 @@
- PROTOCOLS + PROTOCOLS COLLECTIONS ABOUT
@@ -43,7 +43,7 @@
sort: - date added + most recent relevance ☰ filter
@@ -87,25 +87,6 @@

License

Hippocratic License (HL3-CORE) — do no harm. See firstdonoharm.dev. Source code available on Gitea.

- - - - - @@ -251,10 +232,6 @@ - - - + \ No newline at end of file