From 1148c19fd6f2b29aae3cc6e55851c6238256b740 Mon Sep 17 00:00:00 2001 From: Protocolbot Date: Mon, 6 Jul 2026 22:40:34 -0600 Subject: [PATCH] feat: shared rendering, footer, terms of service, git deployment, consistency fixes Rendering consistency: - Create shared renderProtocolPill() and renderCollectionPill() functions used everywhere: library, collection detail, user profile, picker - All protocol/collection modules now look identical across all contexts - Profile page collections and protocols use the same shared renderers - Protocol grids wrapped in .protocol-grid for consistent spacing New features: - Footer with CC-BY 4.0 license link and Terms of Service link - Terms of Service page stating admin reserves right to remove content - CC-BY 4.0 default license for submitted content Deployment: - README now recommends git clone for initial deployment - README documents git pull for updates without data loss - All nav links use #protocols instead of #library Bug fixes (from previous commits, re-applied to current codebase): - Delete redirect: reset currentRoute guard so navigate works after delete - #protocols routing instead of #library - forked_from: null no longer shows as clickable link - Source field suppressed when it duplicates author - Collections say 'curated by' instead of 'authored by' - Breadcrumb home click works from detail pages - Search debounce and double-navigation guard --- README.md | 53 +++++++++---- frontend/app.js | 180 ++++++++++++++++++++++---------------------- frontend/index.html | 27 ++++++- frontend/style.css | 16 +++- 4 files changed, 169 insertions(+), 107 deletions(-) mode change 100755 => 100644 frontend/style.css diff --git a/README.md b/README.md index 67509bb..a809bac 100644 --- a/README.md +++ b/README.md @@ -62,20 +62,39 @@ protocol-droid/ ### On Cloudron 1. Create a LAMP app in Cloudron (no MySQL addon needed — uses SQLite) -2. Upload all files to the app's web root +2. Clone the repo into the app's web root: + ```bash + cd /var/www/html # or your Cloudron app's web root + git clone https://git.medlab.host/ntnsndr/protocol-droid.git . + ``` 3. Ensure the `data/` directory is writable by the web server: ```bash mkdir -p data && chown www-data:www-data data && chmod 775 data ``` 4. The database schema auto-initializes on first API request (creates `data/protocol_droid.db`) 5. Visit the site and register — the **first user to register becomes admin** -5. Optionally load seed protocols: Go to the **About** page and click **"Load seed protocols"** (visible to admins only). This loads 6 example protocols into your library in one click. No curl needed. +6. Optionally load seed protocols: Go to the **About** page and click **"Load seed protocols"** (visible to admins only). This loads 6 example protocols into your library in one click. No curl needed. **No default admin credentials exist.** A fresh deployment has an empty database. The first registration creates the admin account. Seed protocols are optional and must be explicitly loaded. +### Updating + +To update an existing deployment without losing data, simply pull the latest code: + +```bash +cd /path/to/protocol-droid +git pull +``` + +The SQLite database (`data/protocol_droid.db`) is not tracked by git and will be preserved across updates. The schema uses `CREATE TABLE IF NOT EXISTS` so existing tables are never overwritten. + ### On any LAMP server -1. Upload files to your web root (or a subdirectory like `/protocol-droid/`) +1. Clone the repo to your web root (or a subdirectory like `/protocol-droid/`): + ```bash + cd /var/www/html/protocol-droid + git clone https://git.medlab.host/ntnsndr/protocol-droid.git . + ``` 2. Ensure the `data/` directory is writable by the web server 3. Visit the site and register — first user becomes admin @@ -103,7 +122,22 @@ cd protocol-droid php -S localhost:8000 ``` -The SQLite database is created automatically at `data/protocol_droid.db`. The first registration creates the admin account. To load the 6 sample protocols, go to the **About** page and click **"Load seed protocols"** (admins only). +The SQLite database is created automatically at `data/protocol_droid.db`. The first registration creates the admin account. To load the 6 sample protocols (Round Robin Check-In, Consent Decision-Making, etc.): + +```bash +# After registering as admin: +curl -c cookies.txt -X POST http://localhost:8000/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"your-username","password":"your-password"}' + +curl -b cookies.txt -X POST http://localhost:8000/api/seed +``` + +To remove all seed protocols (or any protocols), delete them individually: + +```bash +curl -b cookies.txt -X DELETE http://localhost:8000/api/protocols/round-robin-check-in +``` ## API @@ -158,17 +192,6 @@ practice: >- Use a talking object. Keep time gently. For large groups, break into smaller rounds. ``` -## Customizing the About page - -The About page is plain HTML in `frontend/index.html`, inside the `
` element. To customize it for your deployment: - -1. Edit `frontend/index.html` -2. Find the `` section -3. Modify the text, links, and sections as needed -4. Upload the file to your server - -No rebuild step is needed — the HTML is served directly. The "Seed protocols" section at the bottom is automatically shown only to admin users, so you can leave it or remove it. - ## License Hippocratic License (HL3-CORE) — do no harm. See https://firstdonoharm.dev/ diff --git a/frontend/app.js b/frontend/app.js index 70527aa..3a01444 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,7 +1,6 @@ /* 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++) { @@ -37,9 +36,40 @@ 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); @@ -56,8 +86,7 @@ async function init() { } catch (e) {} renderAuth(); - // Re-route after auth loads so auth-dependent UI renders correctly - if (location.hash && location.hash !== '#library') { + if (location.hash && location.hash !== '#protocols') { route(); } } @@ -80,6 +109,8 @@ function route() { navigate('create-collection'); } else if (hash === 'about') { navigate('about'); + } else if (hash === 'terms') { + navigate('terms'); } else { navigate('library'); } @@ -89,7 +120,6 @@ 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'); @@ -102,14 +132,14 @@ function navigate(view) { 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'; } + if (view === 'about') { location.hash = 'about'; updateAboutPage(); } + if (view === 'terms') { location.hash = 'terms'; } 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' }; + const map = { library: 'protocols/', create: 'protocols/create', collections: 'collections/', 'create-collection': 'collections/create', about: 'about', terms: 'terms' }; path.textContent = map[view] || ''; } @@ -129,9 +159,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 = '' + @@ -194,20 +224,17 @@ 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) { @@ -231,13 +258,17 @@ 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 || ''); }); } - renderProtocolGrid(filtered); + var grid = document.getElementById('protocolGrid'); + if (!filtered.length) { + grid.innerHTML = '
No protocols found. Create one
'; + } else { + grid.innerHTML = filtered.map(renderProtocolPill).join(''); + } renderSortToggle(); } catch (e) { document.getElementById('protocolGrid').innerHTML = '
No protocols found. ' + escapeHtml(e.message) + '
'; @@ -253,7 +284,6 @@ function setSort(mode) { } 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)); @@ -268,7 +298,7 @@ function renderFilters() { 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 = tags.slice(0, 10); } tags = ['all', ...tags]; bar.innerHTML = tags.map(function(t) { @@ -293,26 +323,6 @@ 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; @@ -339,10 +349,10 @@ 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') ? '' : ''; + const forkNotice = (p.forked_from && p.forked_from !== 'null' && p.forked_from !== null) ? '' : ''; const sourceLine = (p.source && p.source !== '@' + (p.author || '')) ? 'SOURCE: ' + escapeHtml(p.source) : ''; var sourceLink = p.source_url ? ' · view_original' : ''; - var authorLine = p.author ? (sourceLine ? ' · ' : '') + 'authored by @' + escapeHtml(p.author) + '' : ''; + var authorLine = p.author ? (sourceLine ? ' · ' : '') + 'authored by @' + escapeHtml(p.author) + '' : ''; var dateLine = p.created_at ? ' · added ' + formatDate(p.created_at) : ''; return '
' + tags + '
' + @@ -412,7 +422,7 @@ async function deleteProtocol(slug) { try { await api('/protocols/' + slug, { method: 'DELETE' }); toast('Protocol deleted'); - currentRoute = ''; // Reset so navigate works + currentRoute = ''; navigate('library'); } catch (e) { toast(e.message, true); } } @@ -564,14 +574,9 @@ async function loadCollections() { var grid = document.getElementById('collectionsGrid'); if (!collections.length) { grid.innerHTML = '
No collections yet. Create one
'; - return; + } else { + grid.innerHTML = collections.map(renderCollectionPill).join(''); } - 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) + '
'; } @@ -596,7 +601,6 @@ async function loadProtocolPicker(selectedSlugs) { } catch (e) { pickerProtocolList = []; } - // Don't render the list — it starts empty, user searches to add } function renderPickerList(protocols) { @@ -607,14 +611,11 @@ function renderPickerList(protocols) { } 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) + '
'; + // 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; }).join('') + '
'; } @@ -639,7 +640,6 @@ 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,12 +659,10 @@ 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(); @@ -687,8 +685,6 @@ 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; } @@ -732,24 +728,20 @@ async function showCollectionDetail(slug) { 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
'; + itemsHtml += renderProtocolPill(p); } 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 authorLine = c.author ? (sourceLine ? ' · ' : '') + 'curated 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 + '
' + + 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'); @@ -804,27 +796,38 @@ 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(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('') + - '
' : ''); + (p.length ? '

Protocols (' + p.length + ')

' + + p.map(renderProtocolPill).join('') + + '
' : '') + + (c.length ? '

Collections (' + c.length + ')

' + + c.map(renderCollectionPill).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) { @@ -844,7 +847,6 @@ 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']; @@ -852,4 +854,4 @@ function formatDate(s) { } // --- Boot --- -init(); +init(); \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index f5875d6..1ca2c7a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ Protocol Droid - + @@ -87,6 +87,25 @@

License

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

+ +
+ + + @@ -232,6 +251,10 @@ - + + + \ No newline at end of file diff --git a/frontend/style.css b/frontend/style.css old mode 100755 new mode 100644 index f8bb197..90b07f3 --- a/frontend/style.css +++ b/frontend/style.css @@ -527,4 +527,18 @@ header nav a.active { color: var(--accent-bright); border-color: rgba(0,170,255, .protocol-pill .meta > div:not(.steps) { font-size: 10px; } .detail-header, .detail-section { padding: 16px 14px; border-radius: 12px; } -} \ No newline at end of file +} + +/* Footer */ +.footer { + text-align: center; + padding: 16px; + font-family: var(--mono); + font-size: 11px; + color: var(--ink-dim); +} +.footer a { + color: var(--accent); + text-decoration: none; +} +.footer a:hover { text-decoration: underline; } \ No newline at end of file