refactor: streamline question navigation and loading logic in exam templates
This commit is contained in:
@@ -49,107 +49,7 @@
|
|||||||
<h4>Questions</h4>
|
<h4>Questions</h4>
|
||||||
<div id="menu-list"></div>
|
<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" %}
|
{% include "physics/exam_take_help.html" %}
|
||||||
|
|
||||||
@@ -375,37 +275,6 @@
|
|||||||
// The view sets an `HX-Trigger` header named `saved` when an HTMX save occurs.
|
// The view sets an `HX-Trigger` header named `saved` when an HTMX save occurs.
|
||||||
document.addEventListener('saved', function(evt){
|
document.addEventListener('saved', function(evt){
|
||||||
try {
|
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
|
// Update the question list state locally so the menu reflects answered questions
|
||||||
try {
|
try {
|
||||||
var pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : (document.getElementById('question-number') ? parseInt(document.getElementById('question-number').textContent, 10) - 1 : NaN);
|
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); }
|
} catch (e) { console && console.error && console.error(e); }
|
||||||
});
|
});
|
||||||
</script>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,25 +4,25 @@
|
|||||||
|
|
||||||
<div class="overview-list">
|
<div class="overview-list">
|
||||||
<ol class="overview-questions">
|
<ol class="overview-questions">
|
||||||
{% for q, ans in question_answer_tuples %}
|
{% for q, ans in question_answer_tuples %}
|
||||||
<li class="overview-item" data-qn-index="{{ forloop.counter0 }}">
|
<li class="overview-item" data-qn-index="{{ forloop.counter0 }}">
|
||||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
<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>
|
<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="flex:1; padding-left:0.5rem;">{{ q.stem|truncatechars:140|safe }}</div>
|
||||||
<div style="min-width:6rem; text-align:right;">
|
<div style="min-width:6rem; text-align:right;">
|
||||||
{% if ans %}
|
{% if ans %}
|
||||||
{% if ans.a or ans.b or ans.c or ans.d or ans.e %}
|
{% if ans.a or ans.b or ans.c or ans.d or ans.e %}
|
||||||
<span class="badge bg-success">Answered</span>
|
<span class="badge bg-success">Answered</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Unanswered</span>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">Unanswered</span>
|
<span class="badge bg-secondary">Unanswered</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
</div>
|
||||||
<span class="badge bg-secondary">Unanswered</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
{% endfor %}
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,38 +31,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
// When a question button is clicked, trigger the hidden goto button in the
|
// 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.
|
// main fragment so the server navigates to that question via the normal flow.
|
||||||
document.querySelectorAll('.goto-from-overview').forEach(function(btn){
|
document.querySelectorAll('.goto-from-overview').forEach(function(btn){
|
||||||
btn.addEventListener('click', function(e){
|
btn.addEventListener('click', function(e){
|
||||||
var q = e.currentTarget.dataset.qn;
|
var q = e.currentTarget.dataset.qn;
|
||||||
// Find the form in the currently loaded fragment (it may be the overview itself)
|
// Find the form in the currently loaded fragment (it may be the overview itself)
|
||||||
var form = document.querySelector('#question-fragment form.post-form');
|
var form = document.querySelector('#question-fragment form.post-form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
var goto = form.querySelector('button[name="goto"]') || form.querySelector('#goto-button');
|
var goto = form.querySelector('button[name="goto"]') || form.querySelector('#goto-button');
|
||||||
if (!goto) return;
|
if (!goto) return;
|
||||||
// Set the value and submit
|
// Set the value and submit
|
||||||
if (goto.tagName.toLowerCase() === 'input' || goto.tagName.toLowerCase() === 'button') {
|
if (goto.tagName.toLowerCase() === 'input' || goto.tagName.toLowerCase() === 'button') {
|
||||||
goto.value = q;
|
goto.value = q;
|
||||||
// submit via HTMX by clicking the hidden goto button
|
// submit via HTMX by clicking the hidden goto button
|
||||||
goto.click();
|
goto.click();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Back button simply reloads the current question fragment by triggering a
|
// Back button simply reloads the current question fragment by triggering a
|
||||||
// click on the menu item for the current question (if available) or reloads
|
// click on the menu item for the current question (if available) or reloads
|
||||||
// via HTMX load of the fragment.
|
// via HTMX load of the fragment.
|
||||||
document.getElementById('overview-back-to-question').addEventListener('click', function(){
|
const overviewBackBtn = document.getElementById('overview-back-to-question');
|
||||||
var menuCurrent = document.querySelector('#menu-list .current-question');
|
if (overviewBackBtn) {
|
||||||
if (menuCurrent) { menuCurrent.click(); return; }
|
overviewBackBtn.addEventListener('click', function(){
|
||||||
// fallback: force HTMX to reload the current fragment
|
var menuCurrent = document.querySelector('#menu-list .current-question');
|
||||||
var frag = document.getElementById('question-fragment');
|
if (menuCurrent) { menuCurrent.click(); return; }
|
||||||
if (frag && frag.getAttribute('hx-get')) {
|
// fallback: force HTMX to reload the current fragment
|
||||||
htmx.trigger(frag, 'load');
|
var frag = document.getElementById('question-fragment');
|
||||||
|
if (frag && frag.getAttribute('hx-get')) {
|
||||||
|
htmx.trigger(frag, 'load');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
})();
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="question-fragment"
|
<div id="question-fragment"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-get="{% if cid %}{% url 'sbas:exam_take_fragment' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'sbas:exam_take_fragment_user' pk=exam.pk sk=pos %}{% endif %}" hx-swap="innerHTML">
|
hx-get="{% if cid %}{% url 'sbas:exam_take_fragment' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'sbas:exam_take_fragment_user' pk=exam.pk sk=pos %}{% endif %}" hx-swap="innerHTML">
|
||||||
<!-- HTMX will load the per-question form fragment here -->
|
<!-- HTMX will load the per-question form fragment here -->
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="menu-list" class="collapse d-md-block">
|
<div id="menu-list" class="collapse d-md-block">
|
||||||
<div class="d-flex flex-wrap gap-1" id="menu-buttons" role="toolbar" aria-label="Question navigation">
|
<div class="d-flex flex-wrap gap-1" id="menu-list" role="toolbar" aria-label="Question navigation">
|
||||||
<!-- Buttons injected by JS -->
|
<!-- Buttons injected by JS -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
const answered = window.examQuestionAnswered || [];
|
const answered = window.examQuestionAnswered || [];
|
||||||
const flagged = window.examQuestionFlagged || [];
|
const flagged = window.examQuestionFlagged || [];
|
||||||
const pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : NaN;
|
const pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : NaN;
|
||||||
document.querySelectorAll('#menu-buttons .question-menu-item').forEach(btn => {
|
document.querySelectorAll('#menu-list .question-menu-item').forEach(btn => {
|
||||||
const idx = parseInt(btn.dataset.qn, 10);
|
const idx = parseInt(btn.dataset.qn, 10);
|
||||||
btn.classList.toggle('answered', !!answered[idx]);
|
btn.classList.toggle('answered', !!answered[idx]);
|
||||||
btn.classList.toggle('flagged', !!flagged[idx]);
|
btn.classList.toggle('flagged', !!flagged[idx]);
|
||||||
@@ -130,10 +130,10 @@
|
|||||||
qbutton.dataset.qn = i;
|
qbutton.dataset.qn = i;
|
||||||
qbutton.textContent = (i+1).toString();
|
qbutton.textContent = (i+1).toString();
|
||||||
if (i == {{pos}}) qbutton.classList.add('current-question');
|
if (i == {{pos}}) qbutton.classList.add('current-question');
|
||||||
document.getElementById('menu-buttons').appendChild(qbutton);
|
document.getElementById('menu-list').appendChild(qbutton);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('menu-buttons').addEventListener('click', function(e){
|
document.getElementById('menu-list').addEventListener('click', function(e){
|
||||||
const btn = e.target.closest('.question-menu-item');
|
const btn = e.target.closest('.question-menu-item');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
document.getElementById('goto-button').value = btn.dataset.qn;
|
document.getElementById('goto-button').value = btn.dataset.qn;
|
||||||
@@ -145,6 +145,70 @@
|
|||||||
|
|
||||||
})
|
})
|
||||||
</script>
|
</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);}
|
||||||
|
}
|
||||||
|
|
||||||
|
(function bindBodyListeners(){
|
||||||
|
function doBind(){
|
||||||
|
try{
|
||||||
|
document.body.addEventListener('htmx:beforeRequest', function(evt){
|
||||||
|
console.log("htmx:beforeRequest", 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(); });
|
||||||
|
}catch(e){/* ignore */}
|
||||||
|
}
|
||||||
|
if(document.body) doBind(); else document.addEventListener('DOMContentLoaded', doBind);
|
||||||
|
})();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// Also update after HTMX settle to ensure DOM changes are applied
|
||||||
|
(function(){
|
||||||
|
function bindAfterSettle(){ try{ if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu(); }catch(e){} }
|
||||||
|
if(document.body) document.body.addEventListener('htmx:afterSettle', bindAfterSettle); else document.addEventListener('DOMContentLoaded', function(){ document.body && document.body.addEventListener('htmx:afterSettle', bindAfterSettle); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Update flagged state when flag button is swapped via HTMX
|
// Update flagged state when flag button is swapped via HTMX
|
||||||
document.addEventListener('htmx:afterSwap', function(evt){
|
document.addEventListener('htmx:afterSwap', function(evt){
|
||||||
@@ -163,9 +227,48 @@
|
|||||||
}catch(e){ console && console.error && console.error(e); }
|
}catch(e){ console && console.error && console.error(e); }
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
// Global listener: update menu only after server confirms a save (HX-Trigger 'saved')
|
||||||
|
document.addEventListener('saved', function(evt){
|
||||||
|
try{
|
||||||
|
// Do not display a saved notification; just update menu state
|
||||||
|
var pos = (typeof window.currentQuestionPos === 'number') ? window.currentQuestionPos : (document.getElementById('question-number') ? parseInt(document.getElementById('question-number').textContent, 10) - 1 : NaN);
|
||||||
|
if (!isNaN(pos)) {
|
||||||
|
window.examQuestionAnswered = window.examQuestionAnswered || new Array({{ exam_length }}).fill(false);
|
||||||
|
window.examQuestionAnswered[pos] = true;
|
||||||
|
if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu();
|
||||||
|
}
|
||||||
|
}catch(e){ console && console.error && console.error(e); }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% comment %} Loading styles merged into main CSS block below to avoid duplicate block tags {% endcomment %}
|
||||||
{% block css %}
|
{% block css %}
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
|
:root {
|
||||||
|
--bg: #050607;
|
||||||
|
--text: #e6eef6;
|
||||||
|
--muted: #9aa4ad;
|
||||||
|
--card-bg: #0f1720;
|
||||||
|
--card-border: #1f2a33;
|
||||||
|
--primary: #6ea8ff;
|
||||||
|
--success: #36d07a;
|
||||||
|
--danger: #ff6b6b;
|
||||||
|
--feedback-bg: #071322;
|
||||||
|
--shadow: 0 2px 6px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
/* Loading blur + spinner for question fragment (match physics) */
|
||||||
|
#question-fragment.loading { filter: blur(3px); opacity: 0.9; pointer-events: none; position: relative; }
|
||||||
|
#question-fragment .loading-spinner {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%; top: 50%; transform: translate(-50%, -50%);
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
border: 4px solid rgba(0,0,0,0.12); border-top-color: #6ea8ff;
|
||||||
|
animation: sbas-spin 800ms linear infinite; z-index: 1200;
|
||||||
|
}
|
||||||
|
@keyframes sbas-spin { from { transform: translate(-50%,-50%) rotate(0deg); } to { transform: translate(-50%,-50%) rotate(360deg); } }
|
||||||
|
|
||||||
.form-contents {
|
.form-contents {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -233,6 +336,27 @@
|
|||||||
top: auto !important;
|
top: auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Question menu item states (small pill buttons) */
|
||||||
|
button.question-menu-item {
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background: var(--card-bg);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
button.question-menu-item.answered {
|
||||||
|
border-color: var(--success) !important; /* answered -> success border */
|
||||||
|
}
|
||||||
|
/* flagged items show an icon only; no border change here */
|
||||||
|
button.question-menu-item .question-flag {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #ffc107;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="sba-question-fragment">
|
<div class="sba-question-fragment">
|
||||||
<form method="POST" class="post-form"
|
<form method="POST" class="post-form"
|
||||||
hx-post="{% if cid %}{% url 'sbas:exam_take' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'sbas:exam_take_user' pk=exam.pk sk=pos %}{% endif %}"
|
hx-post="{% if cid %}{% url 'sbas:exam_take' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'sbas:exam_take_user' pk=exam.pk sk=pos %}{% endif %}"
|
||||||
hx-target="#question-fragment" hx-swap="innerHTML">
|
hx-target="#question-fragment" hx-swap="innerHTML">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="form-contents-visible">
|
<div class="form-contents-visible">
|
||||||
<div class="question-stem-fragment d-md-none mb-2">{{ question.stem|safe }}</div>
|
<div class="question-stem-fragment d-md-none mb-2">{{ question.stem|safe }}</div>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
if (Array.isArray(serverAnswered)) window.examQuestionAnswered = serverAnswered;
|
if (Array.isArray(serverAnswered)) window.examQuestionAnswered = serverAnswered;
|
||||||
if (Array.isArray(serverFlagged)) window.examQuestionFlagged = serverFlagged;
|
if (Array.isArray(serverFlagged)) window.examQuestionFlagged = serverFlagged;
|
||||||
for (let i = 0; i < {{ exam_length }}; i++) {
|
for (let i = 0; i < {{ exam_length }}; i++) {
|
||||||
const qbutton = $(`<button class="question-menu-item" name="goto-${i}" data-qn="${i}"><span class="qnum">${i+1}</span></button>`);
|
const qbutton = $(`<button type="button" class="btn btn-sm btn-outline-secondary question-menu-item" name="goto-${i}" data-qn="${i}">${i+1}</button>`);
|
||||||
if (i == {{ pos }}) { qbutton.addClass('current-question'); }
|
if (i == {{ pos }}) { qbutton.addClass('current-question'); }
|
||||||
if (window.examQuestionAnswered && window.examQuestionAnswered[i]) qbutton.addClass('answered');
|
if (window.examQuestionAnswered && window.examQuestionAnswered[i]) qbutton.addClass('answered');
|
||||||
if (window.examQuestionFlagged && window.examQuestionFlagged[i]) qbutton.addClass('flagged');
|
if (window.examQuestionFlagged && window.examQuestionFlagged[i]) qbutton.addClass('flagged');
|
||||||
@@ -109,36 +109,38 @@
|
|||||||
document.querySelectorAll('ul.sba-answer-list li').forEach(li => {
|
document.querySelectorAll('ul.sba-answer-list li').forEach(li => {
|
||||||
if (li.dataset.delegateBound) return;
|
if (li.dataset.delegateBound) return;
|
||||||
li.dataset.delegateBound = '1';
|
li.dataset.delegateBound = '1';
|
||||||
const activate = (el) => {
|
const activate = (el) => {
|
||||||
const choice = el.dataset.ans;
|
const choice = el.dataset.ans;
|
||||||
// update hidden input and visuals
|
// update hidden input and visuals
|
||||||
const hidden = document.getElementById('id_answer');
|
const hidden = document.getElementById('id_answer');
|
||||||
if (hidden) hidden.value = choice;
|
if (hidden) hidden.value = choice;
|
||||||
document.querySelectorAll('ul.sba-answer-list li').forEach(x=>{ x.classList.remove('selected'); x.setAttribute('aria-pressed','false'); });
|
document.querySelectorAll('ul.sba-answer-list li').forEach(x=>{ x.classList.remove('selected'); x.setAttribute('aria-pressed','false'); });
|
||||||
el.classList.add('selected'); el.setAttribute('aria-pressed','true');
|
el.classList.add('selected'); el.setAttribute('aria-pressed','true');
|
||||||
|
|
||||||
|
// Note: do not mark answered here — wait for server 'saved' confirmation
|
||||||
|
|
||||||
// auto-save via HTMX if available and exam isn't in review/completed mode
|
// auto-save via HTMX if available and exam isn't in review/completed mode
|
||||||
if (window.htmx && typeof htmx.ajax === 'function'){
|
if (window.htmx && typeof htmx.ajax === 'function'){
|
||||||
const form = el.closest('form.post-form');
|
const form = el.closest('form.post-form');
|
||||||
const postUrl = form ? (form.getAttribute('hx-post') || form.getAttribute('action')) : null;
|
const postUrl = form ? (form.getAttribute('hx-post') || form.getAttribute('action')) : null;
|
||||||
if (postUrl){
|
if (postUrl){
|
||||||
const values = { 'answer': choice, 'save': '' };
|
const values = { 'answer': choice, 'save': '' };
|
||||||
const headers = {};
|
const headers = {};
|
||||||
if (csrftoken) headers['X-CSRFToken'] = csrftoken;
|
if (csrftoken) headers['X-CSRFToken'] = csrftoken;
|
||||||
htmx.ajax('POST', postUrl, { target: '#question-fragment', swap: 'innerHTML', values: values, headers: headers });
|
htmx.ajax('POST', postUrl, { target: '#question-fragment', swap: 'innerHTML', values: values, headers: headers });
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// fallback: submit the form normally
|
|
||||||
const form = el.closest('form.post-form');
|
|
||||||
if (form){
|
|
||||||
const tmp = document.createElement('input'); tmp.type='hidden'; tmp.name='save'; tmp.value=''; form.appendChild(tmp);
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
} else {
|
||||||
|
// fallback: submit the form normally
|
||||||
|
const form = el.closest('form.post-form');
|
||||||
|
if (form){
|
||||||
|
const tmp = document.createElement('input'); tmp.type='hidden'; tmp.name='save'; tmp.value=''; form.appendChild(tmp);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
li.addEventListener('click', function(e){ try{ activate(this);}catch(ex){ console && console.error && console.error(ex); } });
|
li.addEventListener('click', function(e){ try{ activate(this);}catch(ex){ console && console.error && console.error(ex); } });
|
||||||
li.addEventListener('keydown', function(e){ if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); try{ activate(this);}catch(ex){ console && console.error && console.error(ex);} } });
|
li.addEventListener('keydown', function(e){ if(e.key === 'Enter' || e.key === ' ') { e.preventDefault(); try{ activate(this);}catch(ex){ console && console.error && console.error(ex);} } });
|
||||||
});
|
});
|
||||||
} catch(e){ console && console.error && console.error(e); }
|
} catch(e){ console && console.error && console.error(e); }
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user