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();
|
renderAuth();
|
||||||
|
|
||||||
// Re-route after auth loads so auth-dependent UI renders correctly
|
// Re-route after auth loads so auth-dependent UI renders correctly
|
||||||
if (location.hash && location.hash !== '#protocols') {
|
if (location.hash && location.hash !== '#library') {
|
||||||
route();
|
route();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function route() {
|
function route() {
|
||||||
isRouting = true;
|
|
||||||
const hash = location.hash.slice(1) || 'library';
|
const hash = location.hash.slice(1) || 'library';
|
||||||
if (hash.startsWith('protocols/')) {
|
if (hash.startsWith('protocols/')) {
|
||||||
showDetail(hash.split('/')[1]);
|
showDetail(hash.split('/')[1]);
|
||||||
@@ -82,16 +81,14 @@ function route() {
|
|||||||
} else {
|
} else {
|
||||||
navigate('library');
|
navigate('library');
|
||||||
}
|
}
|
||||||
isRouting = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let searchTimer = null;
|
let searchTimer = null;
|
||||||
let currentRoute = '';
|
let currentRoute = '';
|
||||||
let isRouting = false;
|
|
||||||
|
|
||||||
function navigate(view) {
|
function navigate(view) {
|
||||||
// Prevent double navigation from hashchange
|
// Prevent double navigation from hashchange
|
||||||
if (currentRoute === view && view !== 'library') return;
|
if (currentRoute === view) return;
|
||||||
currentRoute = view;
|
currentRoute = view;
|
||||||
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
|
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
|
||||||
document.getElementById('view-' + view).style.display = 'block';
|
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));
|
document.querySelectorAll('.mobile-panel a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
|
||||||
closeMenu();
|
closeMenu();
|
||||||
updateBreadcrumb(view);
|
updateBreadcrumb(view);
|
||||||
if (!isRouting) {
|
if (view === 'library') { location.hash = 'library'; loadProtocols(); }
|
||||||
if (view === 'library') { location.hash = 'library'; }
|
if (view === 'collections') { location.hash = 'collections'; loadCollections(); }
|
||||||
if (view === 'collections') { location.hash = 'collections'; }
|
|
||||||
if (view === 'about') { location.hash = 'about'; }
|
|
||||||
}
|
|
||||||
if (view === 'library') loadProtocols();
|
|
||||||
if (view === 'collections') loadCollections();
|
|
||||||
if (view === 'create') { editingSlug = null; resetForm(); }
|
if (view === 'create') { editingSlug = null; resetForm(); }
|
||||||
if (view === 'create-collection') { editingCollectionSlug = null; resetCollectionForm(); }
|
if (view === 'create-collection') { editingCollectionSlug = null; resetCollectionForm(); }
|
||||||
if (view === 'about') {}
|
if (view === 'about') { location.hash = 'about'; }
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +313,7 @@ function renderProtocolGrid(protocols) {
|
|||||||
|
|
||||||
// --- Detail ---
|
// --- Detail ---
|
||||||
async function showDetail(slug) {
|
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.querySelectorAll('.view').forEach(v => v.style.display = 'none');
|
||||||
document.getElementById('view-detail').style.display = 'block';
|
document.getElementById('view-detail').style.display = 'block';
|
||||||
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
|
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
|
||||||
@@ -721,7 +713,7 @@ async function saveCollection(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function showCollectionDetail(slug) {
|
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.querySelectorAll('.view').forEach(v => v.style.display = 'none');
|
||||||
document.getElementById('view-collection-detail').style.display = 'block';
|
document.getElementById('view-collection-detail').style.display = 'block';
|
||||||
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
|
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
|
||||||
@@ -794,7 +786,6 @@ async function deleteCollection(slug) {
|
|||||||
|
|
||||||
// --- User Profile ---
|
// --- User Profile ---
|
||||||
async function showUser(username) {
|
async function showUser(username) {
|
||||||
if (!isRouting && location.hash !== '#users/' + username) location.hash = 'users/' + username;
|
|
||||||
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
|
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
|
||||||
document.getElementById('view-detail').style.display = 'block';
|
document.getElementById('view-detail').style.display = 'block';
|
||||||
document.querySelectorAll('header nav a').forEach(a => a.classList.remove('active'));
|
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>';
|
'<div class="info"><h3>' + escapeHtml(col.title) + '</h3><p>' + escapeHtml(col.description||'') + '</p></div></div>';
|
||||||
}).join('') +
|
}).join('') +
|
||||||
'</div>' : '');
|
'</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) {
|
} catch (e) {
|
||||||
document.getElementById('detailContent').innerHTML = '<div class="empty">User not found</div>';
|
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 ---
|
// --- Toast ---
|
||||||
let toastTimer = null;
|
let toastTimer = null;
|
||||||
function toast(msg, isError) {
|
function toast(msg, isError) {
|
||||||
|
|||||||
+28
-8
@@ -4,23 +4,16 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Protocol Droid</title>
|
<title>Protocol Droid</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="frontend/logo.svg">
|
<link rel="stylesheet" href="/frontend/style.css">
|
||||||
<link rel="icon" type="image/x-icon" href="frontend/favicon.ico">
|
|
||||||
<link rel="apple-touch-icon" href="frontend/icon-192.png">
|
|
||||||
<link rel="manifest" href="manifest.json">
|
|
||||||
<link rel="stylesheet" href="frontend/style.css">
|
|
||||||
<meta name="theme-color" content="#0a1929">
|
<meta name="theme-color" content="#0a1929">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<div class="breadcrumb" onclick="navigate('library')">
|
<div class="breadcrumb" onclick="navigate('library')">
|
||||||
<img src="frontend/logo.svg" class="header-logo" alt="">
|
|
||||||
<div class="crumb-text">
|
|
||||||
<span><span class="prompt">$</span> protocol-droid</span>
|
<span><span class="prompt">$</span> protocol-droid</span>
|
||||||
<span class="crumb-path" id="crumbPath"></span>
|
<span class="crumb-path" id="crumbPath"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<nav id="navBar">
|
<nav id="navBar">
|
||||||
<a href="#library" class="active" data-view="library">PROTOCOLS</a>
|
<a href="#library" class="active" data-view="library">PROTOCOLS</a>
|
||||||
<a href="#collections" data-view="collections">COLLECTIONS</a>
|
<a href="#collections" data-view="collections">COLLECTIONS</a>
|
||||||
@@ -150,6 +143,8 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" class="btn primary">Save Protocol</button>
|
<button type="submit" class="btn primary">Save Protocol</button>
|
||||||
<button type="button" class="btn" onclick="navigate('library')">Cancel</button>
|
<button type="button" class="btn" onclick="navigate('library')">Cancel</button>
|
||||||
|
<button type="button" class="btn" onclick="document.getElementById('yamlImportFile').click()">Import YAML</button>
|
||||||
|
<input type="file" id="yamlImportFile" accept=".yaml,.yml" style="display:none" onchange="importYamlToForm(event)">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -237,6 +232,31 @@
|
|||||||
<!-- TOAST -->
|
<!-- TOAST -->
|
||||||
<div class="toast" id="toast" style="display:none"></div>
|
<div class="toast" id="toast" style="display:none"></div>
|
||||||
|
|
||||||
|
<!-- PASSWORD CHANGE MODAL -->
|
||||||
|
<div class="modal-overlay" id="pwModal" style="display:none">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Change Password</h2>
|
||||||
|
<form id="pwForm" onsubmit="handleChangePassword(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Current Password</label>
|
||||||
|
<input type="password" id="pwCurrent" required placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>New Password</label>
|
||||||
|
<input type="password" id="pwNew" required minlength="8" placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Confirm New Password</label>
|
||||||
|
<input type="password" id="pwConfirm" required minlength="8" placeholder="••••••••">
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn primary">Change Password</button>
|
||||||
|
<button type="button" class="btn" onclick="closePwModal()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/frontend/app.js"></script>
|
<script src="/frontend/app.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user