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
This commit is contained in:
@@ -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 `<section id="view-about">` element. To customize it for your deployment:
|
||||
|
||||
1. Edit `frontend/index.html`
|
||||
2. Find the `<!-- ABOUT VIEW -->` 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/
|
||||
|
||||
+90
-88
@@ -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 <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++) {
|
||||
@@ -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 '<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>' +
|
||||
(sourceLabel ? '<div>' + escapeHtml(sourceLabel) + '</div>' : '') + '</div></div>';
|
||||
}
|
||||
|
||||
function renderCollectionPill(c) {
|
||||
var sourceLabel = '';
|
||||
if (c.source && c.source !== '@' + (c.author || '')) {
|
||||
sourceLabel = c.source;
|
||||
} else if (c.author) {
|
||||
sourceLabel = '@' + c.author;
|
||||
}
|
||||
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>' +
|
||||
(sourceLabel ? '<div>' + escapeHtml(sourceLabel) + '</div>' : '') + '</div></div>';
|
||||
}
|
||||
|
||||
// --- 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 = '<a href="#user/' + currentUser.username + '" class="user-link">@' + currentUser.username + '</a>' +
|
||||
area.innerHTML = '<a href="#users/' + 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>' +
|
||||
mobileAuth.innerHTML = '<a href="#users/' + 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>' +
|
||||
@@ -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 = '<div class="empty">No protocols found. <a href="#create" onclick="navigate(\'create\');return false">Create one</a></div>';
|
||||
} else {
|
||||
grid.innerHTML = filtered.map(renderProtocolPill).join('');
|
||||
}
|
||||
renderSortToggle();
|
||||
} catch (e) {
|
||||
document.getElementById('protocolGrid').innerHTML = '<div class="empty">No protocols found. ' + escapeHtml(e.message) + '</div>';
|
||||
@@ -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 = '<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;
|
||||
@@ -339,10 +349,10 @@ function renderDetail(p) {
|
||||
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 && p.forked_from !== 'null') ? '<div class="fork-notice">forked from: <a href="#protocols/' + p.forked_from + '" onclick="showDetail(\'' + p.forked_from + '\');return false">' + p.forked_from + '</a></div>' : '';
|
||||
const forkNotice = (p.forked_from && p.forked_from !== 'null' && p.forked_from !== null) ? '<div class="fork-notice">forked from: <a href="#protocols/' + p.forked_from + '" onclick="showDetail(\'' + p.forked_from + '\');return false">' + escapeHtml(p.forked_from) + '</a></div>' : '';
|
||||
const sourceLine = (p.source && p.source !== '@' + (p.author || '')) ? 'SOURCE: ' + escapeHtml(p.source) : '';
|
||||
var sourceLink = p.source_url ? ' · <a href="' + escapeHtml(p.source_url) + '" target="_blank">view_original</a>' : '';
|
||||
var authorLine = p.author ? (sourceLine ? ' · ' : '') + 'authored by <a href="#user/' + p.author + '" onclick="showUser(\'' + p.author + '\');return false">@' + escapeHtml(p.author) + '</a>' : '';
|
||||
var authorLine = p.author ? (sourceLine ? ' · ' : '') + 'authored by <a href="#users/' + p.author + '" onclick="showUser(\'' + p.author + '\');return false">@' + escapeHtml(p.author) + '</a>' : '';
|
||||
var dateLine = p.created_at ? ' · added ' + formatDate(p.created_at) : '';
|
||||
|
||||
return '<div class="detail-header"><div class="tag-row">' + tags + '</div>' +
|
||||
@@ -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 = '<div class="empty">No collections yet. <a href="#" onclick="navigate(\'create-collection\');return false">Create one</a></div>';
|
||||
return;
|
||||
} else {
|
||||
grid.innerHTML = collections.map(renderCollectionPill).join('');
|
||||
}
|
||||
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>';
|
||||
}
|
||||
@@ -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 = '<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>';
|
||||
// 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('') + '</div>';
|
||||
}
|
||||
|
||||
@@ -639,7 +640,6 @@ function renderSelectedList() {
|
||||
}
|
||||
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) + '">' +
|
||||
@@ -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 += '<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>';
|
||||
itemsHtml += renderProtocolPill(p);
|
||||
} 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 && c.source !== '@' + (c.author || '')) ? 'SOURCE: ' + escapeHtml(c.source) : '';
|
||||
var authorLine = c.author ? (sourceLine ? ' · ' : '') + 'curated by <a href="#user/' + c.author + '" onclick="showUser(\'' + c.author + '\');return false">@' + escapeHtml(c.author) + '</a>' : '';
|
||||
var dateLine = c.created_at ? ' · added ' + formatDate(c.created_at) : '';
|
||||
var authorLine = c.author ? (sourceLine ? ' · ' : '') + 'curated by <a href="#users/' + c.author + '" onclick="showUser(\'' + c.author + '\');return false">@' + escapeHtml(c.author) + '</a>' : '';
|
||||
var dateLine = c.created_at ? ' · added ' + 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>' +
|
||||
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');
|
||||
@@ -804,27 +796,38 @@ async function showUser(username) {
|
||||
'<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>' : '');
|
||||
(p.length ? '<div class="detail-section"><h2>Protocols (' + p.length + ')</h2><div class="protocol-grid">' +
|
||||
p.map(renderProtocolPill).join('') +
|
||||
'</div></div>' : '') +
|
||||
(c.length ? '<div class="detail-section"><h2>Collections (' + c.length + ')</h2><div class="protocol-grid">' +
|
||||
c.map(renderCollectionPill).join('') +
|
||||
'</div></div>' : '');
|
||||
document.getElementById('detailActions').innerHTML = '';
|
||||
} catch (e) {
|
||||
document.getElementById('detailContent').innerHTML = '<div class="empty">User not found</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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'];
|
||||
|
||||
+25
-2
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Protocol Droid</title>
|
||||
<link rel="stylesheet" href="/frontend/style.css">
|
||||
<link rel="stylesheet" href="frontend/style.css">
|
||||
<meta name="theme-color" content="#0a1929">
|
||||
</head>
|
||||
<body>
|
||||
@@ -87,6 +87,25 @@
|
||||
<h2>License</h2>
|
||||
<p class="outcome">Hippocratic License (HL3-CORE) — do no harm. See <a href="https://firstdonoharm.dev/" target="_blank">firstdonoharm.dev</a>. Source code available on <a href="https://git.medlab.host/ntnsndr/protocol-droid" target="_blank">Gitea</a>.</p>
|
||||
</div>
|
||||
<div class="detail-section" id="seedSection" style="display:none">
|
||||
<h2>Seed protocols</h2>
|
||||
<p class="outcome">Load a starter set of 6 example protocols (Round Robin Check-In, Consent Decision-Making, Appreciative Apology, Temperature Reading, Dot Voting, Fishbowl Discussion) into your library.</p>
|
||||
<div class="actions">
|
||||
<button class="btn primary" id="seedBtn" onclick="loadSeedProtocols()">Load seed protocols</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TERMS OF SERVICE VIEW -->
|
||||
<section class="view" id="view-terms" style="display:none">
|
||||
<div class="detail-header">
|
||||
<h1>Terms of Service</h1>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<p class="outcome">By submitting protocols or collections to this site, you acknowledge that the site administrator reserves the right to remove any submitted content at any time, for any reason, without notice.</p>
|
||||
<p class="outcome" style="margin-top:12px">Submitted content is licensed by its authors under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank">Creative Commons Attribution 4.0 (CC-BY)</a> unless otherwise specified.</p>
|
||||
<p class="outcome" style="margin-top:12px">This software itself is licensed under the <a href="https://firstdonoharm.dev/" target="_blank">Hippocratic License (HL3-CORE)</a>.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PROTOCOL DETAIL VIEW -->
|
||||
@@ -232,6 +251,10 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="/frontend/app.js"></script>
|
||||
<footer class="footer">
|
||||
<span>Protocols licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank">CC-BY 4.0</a> · <a href="#terms" onclick="navigate('terms');return false">Terms of Service</a></span>
|
||||
</footer>
|
||||
|
||||
<script src="frontend/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Executable → Regular
+14
@@ -528,3 +528,17 @@ header nav a.active { color: var(--accent-bright); border-color: rgba(0,170,255,
|
||||
|
||||
.detail-header, .detail-section { padding: 16px 14px; border-radius: 12px; }
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
Reference in New Issue
Block a user