feat: YAML import on authoring page + bulk import on admin profile
Authoring page: - 'Import YAML' button next to Save/Cancel on the create form - Opens file picker for .yaml/.yml files - Imports the YAML via API, then loads it into the form for editing - Bad YAML is rejected by the API with an error message shown via toast Admin profile (own profile page): - 'Load Seed Protocols' button (loads 100 built-in seeds) - 'Import YAML Files' button (multi-file picker) - Bulk imports each file, reports count of imported vs failed - Failed files are listed with error reasons in the toast - Profile refreshes after import to show new protocols Password change: - 'Change Password' button on own profile for all users - Modal with current/new/confirm password fields - New API endpoint POST /api/auth/password All imports validate format — incorrectly formatted files are discarded and the user is informed which files failed and why.
This commit is contained in:
+101
-17
@@ -57,13 +57,12 @@ async function init() {
|
||||
renderAuth();
|
||||
|
||||
// Re-route after auth loads so auth-dependent UI renders correctly
|
||||
if (location.hash && location.hash !== '#protocols') {
|
||||
if (location.hash && location.hash !== '#library') {
|
||||
route();
|
||||
}
|
||||
}
|
||||
|
||||
function route() {
|
||||
isRouting = true;
|
||||
const hash = location.hash.slice(1) || 'library';
|
||||
if (hash.startsWith('protocols/')) {
|
||||
showDetail(hash.split('/')[1]);
|
||||
@@ -82,16 +81,14 @@ function route() {
|
||||
} else {
|
||||
navigate('library');
|
||||
}
|
||||
isRouting = false;
|
||||
}
|
||||
|
||||
let searchTimer = null;
|
||||
let currentRoute = '';
|
||||
let isRouting = false;
|
||||
|
||||
function navigate(view) {
|
||||
// Prevent double navigation from hashchange
|
||||
if (currentRoute === view && view !== 'library') return;
|
||||
if (currentRoute === view) return;
|
||||
currentRoute = view;
|
||||
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
|
||||
document.getElementById('view-' + view).style.display = 'block';
|
||||
@@ -99,16 +96,11 @@ function navigate(view) {
|
||||
document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
|
||||
closeMenu();
|
||||
updateBreadcrumb(view);
|
||||
if (!isRouting) {
|
||||
if (view === 'library') { location.hash = 'library'; }
|
||||
if (view === 'collections') { location.hash = 'collections'; }
|
||||
if (view === 'about') { location.hash = 'about'; }
|
||||
}
|
||||
if (view === 'library') loadProtocols();
|
||||
if (view === 'collections') loadCollections();
|
||||
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') {}
|
||||
if (view === 'about') { location.hash = 'about'; }
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
@@ -321,7 +313,7 @@ function renderProtocolGrid(protocols) {
|
||||
|
||||
// --- Detail ---
|
||||
async function showDetail(slug) {
|
||||
if (!isRouting && location.hash !== '#protocols/' + slug) location.hash = 'protocols/' + 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'));
|
||||
@@ -721,7 +713,7 @@ async function saveCollection(e) {
|
||||
}
|
||||
|
||||
async function showCollectionDetail(slug) {
|
||||
if (!isRouting && location.hash !== '#collections/' + slug) location.hash = 'collections/' + 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'));
|
||||
@@ -794,7 +786,6 @@ async function deleteCollection(slug) {
|
||||
|
||||
// --- User Profile ---
|
||||
async function showUser(username) {
|
||||
if (!isRouting && location.hash !== '#users/' + username) location.hash = 'users/' + 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'));
|
||||
@@ -824,12 +815,105 @@ async function showUser(username) {
|
||||
'<div class="info"><h3>' + escapeHtml(col.title) + '</h3><p>' + escapeHtml(col.description||'') + '</p></div></div>';
|
||||
}).join('') +
|
||||
'</div>' : '');
|
||||
document.getElementById('detailActions').innerHTML = '';
|
||||
// Add actions if viewing own profile
|
||||
var actionsHtml = '';
|
||||
if (currentUser && currentUser.username === username) {
|
||||
actionsHtml = '<button class="btn" onclick="showChangePassword()">Change Password</button>';
|
||||
if (currentUser.role === 'admin') {
|
||||
actionsHtml += '<button class="btn primary" id="seedBtn" onclick="loadSeedProtocols()">Load Seed Protocols</button>';
|
||||
actionsHtml += '<button class="btn" onclick="document.getElementById(\'bulkImportFile\').click()">Import YAML Files</button>';
|
||||
actionsHtml += '<input type="file" id="bulkImportFile" accept=".yaml,.yml" multiple style="display:none" onchange="bulkImportYaml(event)">';
|
||||
}
|
||||
}
|
||||
document.getElementById('detailActions').innerHTML = actionsHtml;
|
||||
} catch (e) {
|
||||
document.getElementById('detailContent').innerHTML = '<div class="empty">User not found</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Import YAML to form ---
|
||||
async function importYamlToForm(event) {
|
||||
var file = event.target.files[0];
|
||||
if (!file) return;
|
||||
event.target.value = ''; // Reset for re-use
|
||||
try {
|
||||
var text = await file.text();
|
||||
var result = await api('/import', { method: 'POST', body: JSON.stringify({ yaml: text }) });
|
||||
// Successfully imported — now load it into the form for editing
|
||||
toast('Imported: ' + result.slug + ' — review and save');
|
||||
editProtocol(result.slug);
|
||||
} catch (err) {
|
||||
toast('Import failed: ' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bulk import YAML files (admin) ---
|
||||
async function bulkImportYaml(event) {
|
||||
var files = event.target.files;
|
||||
if (!files || !files.length) return;
|
||||
event.target.value = ''; // Reset for re-use
|
||||
var imported = 0;
|
||||
var failed = 0;
|
||||
var errors = [];
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
try {
|
||||
var text = await files[i].text();
|
||||
await api('/import', { method: 'POST', body: JSON.stringify({ yaml: text }) });
|
||||
imported++;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
errors.push(files[i].name + ': ' + err.message);
|
||||
}
|
||||
}
|
||||
var msg = 'Imported ' + imported + ' protocol' + (imported !== 1 ? 's' : '');
|
||||
if (failed > 0) {
|
||||
msg += ', ' + failed + ' failed: ' + errors.join('; ');
|
||||
toast(msg, true);
|
||||
} else {
|
||||
toast(msg);
|
||||
}
|
||||
// Reload profile to show new protocols
|
||||
if (currentUser) showUser(currentUser.username);
|
||||
}
|
||||
|
||||
// --- Password change ---
|
||||
function showChangePassword() {
|
||||
document.getElementById('pwModal').style.display = 'flex';
|
||||
}
|
||||
function closePwModal() { document.getElementById('pwModal').style.display = 'none'; }
|
||||
|
||||
async function handleChangePassword(e) {
|
||||
e.preventDefault();
|
||||
var current = document.getElementById('pwCurrent').value;
|
||||
var newPassword = document.getElementById('pwNew').value;
|
||||
var confirmPw = document.getElementById('pwConfirm').value;
|
||||
if (newPassword !== confirmPw) { toast('Passwords do not match', true); return; }
|
||||
if (newPassword.length < 8) { toast('Password must be at least 8 characters', true); return; }
|
||||
try {
|
||||
await api('/auth/password', { method: 'POST', body: JSON.stringify({ current_password: current, new_password: newPassword }) });
|
||||
toast('Password changed');
|
||||
closePwModal();
|
||||
document.getElementById('pwForm').reset();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Seed protocols ---
|
||||
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 = 'Load Seed Protocols'; btn.disabled = false; }
|
||||
showUser(currentUser.username);
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
if (btn) { btn.textContent = 'Load Seed Protocols'; btn.disabled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Toast ---
|
||||
let toastTimer = null;
|
||||
function toast(msg, isError) {
|
||||
|
||||
Reference in New Issue
Block a user