refactor: streamline question navigation and loading logic in exam templates
This commit is contained in:
@@ -49,107 +49,7 @@
|
||||
<h4>Questions</h4>
|
||||
<div id="menu-list"></div>
|
||||
|
||||
<script>
|
||||
// Minimal per-question status injected by view: answered/flagged arrays
|
||||
window.examQuestionAnswered = {{ answered_json|safe }};
|
||||
window.examQuestionFlagged = {{ flagged_json|safe }};
|
||||
// Update the visual state of the question menu based on global arrays
|
||||
window.updateQuestionMenu = function(){
|
||||
console.log("Updating question menu");
|
||||
try{
|
||||
const answered = window.examQuestionAnswered || [];
|
||||
const flagged = window.examQuestionFlagged || [];
|
||||
const pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : NaN;
|
||||
document.querySelectorAll('#menu-list .question-menu-item').forEach(btn => {
|
||||
const idx = parseInt(btn.dataset.qn, 10);
|
||||
btn.classList.toggle('answered', !!answered[idx]);
|
||||
btn.classList.toggle('flagged', !!flagged[idx]);
|
||||
btn.classList.toggle('current-question', idx === pos);
|
||||
// manage flag icon presence
|
||||
const hasIcon = btn.querySelector('.question-flag') !== null;
|
||||
if (flagged[idx] && !hasIcon) {
|
||||
const el = document.createElement('span'); el.className = 'question-flag'; el.setAttribute('aria-hidden','true'); el.textContent = '⚑'; btn.appendChild(el);
|
||||
} else if (!flagged[idx] && hasIcon) {
|
||||
const el = btn.querySelector('.question-flag'); if(el) el.remove();
|
||||
}
|
||||
});
|
||||
}catch(e){ console && console.error && console.error(e); }
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
// Show a blur + spinner on the question fragment while HTMX loads it
|
||||
(function(){
|
||||
function showLoading(){
|
||||
try{
|
||||
const frag = document.getElementById('question-fragment');
|
||||
if(!frag) return;
|
||||
frag.classList.add('loading');
|
||||
if(!frag.querySelector('.loading-spinner')){
|
||||
const s = document.createElement('div');
|
||||
s.className = 'loading-spinner';
|
||||
s.setAttribute('aria-hidden','true');
|
||||
frag.appendChild(s);
|
||||
}
|
||||
}catch(e){console && console.error && console.error(e);}
|
||||
}
|
||||
|
||||
function hideLoading(){
|
||||
try{
|
||||
const frag = document.getElementById('question-fragment');
|
||||
if(!frag) return;
|
||||
frag.classList.remove('loading');
|
||||
const s = frag.querySelector('.loading-spinner'); if(s) s.remove();
|
||||
}catch(e){console && console.error && console.error(e);}
|
||||
}
|
||||
|
||||
document.body.addEventListener('htmx:beforeRequest', function(evt){
|
||||
try{
|
||||
const target = (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
|
||||
// if the request targets the question fragment, show loading
|
||||
if(target && (target.id === 'question-fragment' || (target.closest && target.closest('#question-fragment')))){
|
||||
showLoading();
|
||||
}
|
||||
}catch(e){/* ignore */}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt){
|
||||
try{
|
||||
const target = (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
|
||||
if(target && (target.id === 'question-fragment' || (target.closest && target.closest('#question-fragment')))){
|
||||
hideLoading();
|
||||
}
|
||||
}catch(e){}
|
||||
});
|
||||
|
||||
// also hide after settle to be safe
|
||||
document.body.addEventListener('htmx:afterSettle', function(evt){ hideLoading(); });
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Also update after HTMX settle to ensure DOM changes are applied
|
||||
document.body.addEventListener('htmx:afterSettle', function(){
|
||||
try{ if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu(); }catch(e){}
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Listen for flag button swaps (HTMX) and update the local menu state
|
||||
document.addEventListener('htmx:afterSwap', function(evt){
|
||||
try{
|
||||
// read the current flag button in DOM (safer than relying on event target)
|
||||
var container = document.getElementById('flag-button-container');
|
||||
if(!container) return;
|
||||
var btn = container.querySelector('button');
|
||||
if(!btn) return;
|
||||
var flaggedNow = btn.classList.contains('btn-warning');
|
||||
var pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : NaN;
|
||||
if(!isNaN(pos)){
|
||||
window.examQuestionFlagged = window.examQuestionFlagged || new Array({{ exam_length }}).fill(false);
|
||||
window.examQuestionFlagged[pos] = !!flaggedNow;
|
||||
if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu();
|
||||
}
|
||||
}catch(e){ console && console.error && console.error(e); }
|
||||
});
|
||||
</script>
|
||||
|
||||
{% include "physics/exam_take_help.html" %}
|
||||
|
||||
@@ -375,37 +275,6 @@
|
||||
// The view sets an `HX-Trigger` header named `saved` when an HTMX save occurs.
|
||||
document.addEventListener('saved', function(evt){
|
||||
try {
|
||||
// Prefer toastr if available
|
||||
if (typeof toastr !== 'undefined' && toastr && typeof toastr.success === 'function') {
|
||||
toastr.success('Saved');
|
||||
return;
|
||||
}
|
||||
|
||||
// Lightweight fallback toast
|
||||
const id = 'htmx-save-toast';
|
||||
let el = document.getElementById(id);
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = id;
|
||||
el.style.position = 'fixed';
|
||||
el.style.top = '1rem';
|
||||
el.style.right = '1rem';
|
||||
el.style.zIndex = 2000;
|
||||
el.style.padding = '0.6rem 0.9rem';
|
||||
el.style.background = 'rgba(40,167,69,0.95)';
|
||||
el.style.color = '#fff';
|
||||
el.style.borderRadius = '0.4rem';
|
||||
el.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
|
||||
el.style.fontWeight = '600';
|
||||
el.style.opacity = '0';
|
||||
el.style.transition = 'opacity 220ms ease-in-out';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
el.textContent = 'Saved';
|
||||
// show
|
||||
requestAnimationFrame(function(){ el.style.opacity = '1'; });
|
||||
// hide after 1.6s
|
||||
setTimeout(function(){ el.style.opacity = '0'; }, 1600);
|
||||
// Update the question list state locally so the menu reflects answered questions
|
||||
try {
|
||||
var pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : (document.getElementById('question-number') ? parseInt(document.getElementById('question-number').textContent, 10) - 1 : NaN);
|
||||
@@ -423,4 +292,105 @@
|
||||
} catch (e) { console && console.error && console.error(e); }
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Minimal per-question status injected by view: answered/flagged arrays
|
||||
window.examQuestionAnswered = {{ answered_json|safe }};
|
||||
window.examQuestionFlagged = {{ flagged_json|safe }};
|
||||
// Update the visual state of the question menu based on global arrays
|
||||
window.updateQuestionMenu = function(){
|
||||
console.log("Updating question menu");
|
||||
try{
|
||||
const answered = window.examQuestionAnswered || [];
|
||||
const flagged = window.examQuestionFlagged || [];
|
||||
const pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : NaN;
|
||||
document.querySelectorAll('#menu-list .question-menu-item').forEach(btn => {
|
||||
const idx = parseInt(btn.dataset.qn, 10);
|
||||
btn.classList.toggle('answered', !!answered[idx]);
|
||||
btn.classList.toggle('flagged', !!flagged[idx]);
|
||||
btn.classList.toggle('current-question', idx === pos);
|
||||
// manage flag icon presence
|
||||
const hasIcon = btn.querySelector('.question-flag') !== null;
|
||||
if (flagged[idx] && !hasIcon) {
|
||||
const el = document.createElement('span'); el.className = 'question-flag'; el.setAttribute('aria-hidden','true'); el.textContent = '⚑'; btn.appendChild(el);
|
||||
} else if (!flagged[idx] && hasIcon) {
|
||||
const el = btn.querySelector('.question-flag'); if(el) el.remove();
|
||||
}
|
||||
});
|
||||
}catch(e){ console && console.error && console.error(e); }
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
// Show a blur + spinner on the question fragment while HTMX loads it
|
||||
(function(){
|
||||
function showLoading(){
|
||||
try{
|
||||
const frag = document.getElementById('question-fragment');
|
||||
if(!frag) return;
|
||||
frag.classList.add('loading');
|
||||
if(!frag.querySelector('.loading-spinner')){
|
||||
const s = document.createElement('div');
|
||||
s.className = 'loading-spinner';
|
||||
s.setAttribute('aria-hidden','true');
|
||||
frag.appendChild(s);
|
||||
}
|
||||
}catch(e){console && console.error && console.error(e);}
|
||||
}
|
||||
|
||||
function hideLoading(){
|
||||
try{
|
||||
const frag = document.getElementById('question-fragment');
|
||||
if(!frag) return;
|
||||
frag.classList.remove('loading');
|
||||
const s = frag.querySelector('.loading-spinner'); if(s) s.remove();
|
||||
}catch(e){console && console.error && console.error(e);}
|
||||
}
|
||||
|
||||
document.body.addEventListener('htmx:beforeRequest', function(evt){
|
||||
try{
|
||||
const target = (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
|
||||
// if the request targets the question fragment, show loading
|
||||
if(target && (target.id === 'question-fragment' || (target.closest && target.closest('#question-fragment')))){
|
||||
showLoading();
|
||||
}
|
||||
}catch(e){/* ignore */}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt){
|
||||
try{
|
||||
const target = (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
|
||||
if(target && (target.id === 'question-fragment' || (target.closest && target.closest('#question-fragment')))){
|
||||
hideLoading();
|
||||
}
|
||||
}catch(e){}
|
||||
});
|
||||
|
||||
// also hide after settle to be safe
|
||||
document.body.addEventListener('htmx:afterSettle', function(evt){ hideLoading(); });
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Also update after HTMX settle to ensure DOM changes are applied
|
||||
document.body.addEventListener('htmx:afterSettle', function(){
|
||||
try{ if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu(); }catch(e){}
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Listen for flag button swaps (HTMX) and update the local menu state
|
||||
document.addEventListener('htmx:afterSwap', function(evt){
|
||||
try{
|
||||
// read the current flag button in DOM (safer than relying on event target)
|
||||
var container = document.getElementById('flag-button-container');
|
||||
if(!container) return;
|
||||
var btn = container.querySelector('button');
|
||||
if(!btn) return;
|
||||
var flaggedNow = btn.classList.contains('btn-warning');
|
||||
var pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : NaN;
|
||||
if(!isNaN(pos)){
|
||||
window.examQuestionFlagged = window.examQuestionFlagged || new Array({{ exam_length }}).fill(false);
|
||||
window.examQuestionFlagged[pos] = !!flaggedNow;
|
||||
if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu();
|
||||
}
|
||||
}catch(e){ console && console.error && console.error(e); }
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,25 +4,25 @@
|
||||
|
||||
<div class="overview-list">
|
||||
<ol class="overview-questions">
|
||||
{% for q, ans in question_answer_tuples %}
|
||||
<li class="overview-item" data-qn-index="{{ forloop.counter0 }}">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<button class="btn btn-sm btn-outline-secondary goto-from-overview" data-qn="{{ forloop.counter0 }}">{{ forloop.counter }}</button>
|
||||
<div style="flex:1; padding-left:0.5rem;">{{ q.stem|truncatechars:140|safe }}</div>
|
||||
<div style="min-width:6rem; text-align:right;">
|
||||
{% if ans %}
|
||||
{% if ans.a or ans.b or ans.c or ans.d or ans.e %}
|
||||
<span class="badge bg-success">Answered</span>
|
||||
{% for q, ans in question_answer_tuples %}
|
||||
<li class="overview-item" data-qn-index="{{ forloop.counter0 }}">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<button class="btn btn-sm btn-outline-secondary goto-from-overview" data-qn="{{ forloop.counter0 }}">{{ forloop.counter }}</button>
|
||||
<div style="flex:1; padding-left:0.5rem;">{{ q.stem|truncatechars:140|safe }}</div>
|
||||
<div style="min-width:6rem; text-align:right;">
|
||||
{% if ans %}
|
||||
{% if ans.a or ans.b or ans.c or ans.d or ans.e %}
|
||||
<span class="badge bg-success">Answered</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Unanswered</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Unanswered</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Unanswered</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -31,38 +31,41 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
(function(){
|
||||
// When a question button is clicked, trigger the hidden goto button in the
|
||||
// main fragment so the server navigates to that question via the normal flow.
|
||||
document.querySelectorAll('.goto-from-overview').forEach(function(btn){
|
||||
btn.addEventListener('click', function(e){
|
||||
var q = e.currentTarget.dataset.qn;
|
||||
document.querySelectorAll('.goto-from-overview').forEach(function(btn){
|
||||
btn.addEventListener('click', function(e){
|
||||
var q = e.currentTarget.dataset.qn;
|
||||
// Find the form in the currently loaded fragment (it may be the overview itself)
|
||||
var form = document.querySelector('#question-fragment form.post-form');
|
||||
if (!form) return;
|
||||
var goto = form.querySelector('button[name="goto"]') || form.querySelector('#goto-button');
|
||||
if (!goto) return;
|
||||
var form = document.querySelector('#question-fragment form.post-form');
|
||||
if (!form) return;
|
||||
var goto = form.querySelector('button[name="goto"]') || form.querySelector('#goto-button');
|
||||
if (!goto) return;
|
||||
// Set the value and submit
|
||||
if (goto.tagName.toLowerCase() === 'input' || goto.tagName.toLowerCase() === 'button') {
|
||||
goto.value = q;
|
||||
if (goto.tagName.toLowerCase() === 'input' || goto.tagName.toLowerCase() === 'button') {
|
||||
goto.value = q;
|
||||
// submit via HTMX by clicking the hidden goto button
|
||||
goto.click();
|
||||
}
|
||||
goto.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Back button simply reloads the current question fragment by triggering a
|
||||
// click on the menu item for the current question (if available) or reloads
|
||||
// via HTMX load of the fragment.
|
||||
document.getElementById('overview-back-to-question').addEventListener('click', function(){
|
||||
var menuCurrent = document.querySelector('#menu-list .current-question');
|
||||
if (menuCurrent) { menuCurrent.click(); return; }
|
||||
// fallback: force HTMX to reload the current fragment
|
||||
var frag = document.getElementById('question-fragment');
|
||||
if (frag && frag.getAttribute('hx-get')) {
|
||||
htmx.trigger(frag, 'load');
|
||||
const overviewBackBtn = document.getElementById('overview-back-to-question');
|
||||
if (overviewBackBtn) {
|
||||
overviewBackBtn.addEventListener('click', function(){
|
||||
var menuCurrent = document.querySelector('#menu-list .current-question');
|
||||
if (menuCurrent) { menuCurrent.click(); return; }
|
||||
// fallback: force HTMX to reload the current fragment
|
||||
var frag = document.getElementById('question-fragment');
|
||||
if (frag && frag.getAttribute('hx-get')) {
|
||||
htmx.trigger(frag, 'load');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user