362 lines
17 KiB
HTML
Executable File
362 lines
17 KiB
HTML
Executable File
{% extends 'base.html' %}
|
|
|
|
{% block content %}
|
|
<div class="py-3">
|
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
|
|
|
<span id="user-id" class="text-muted small">
|
|
{% if request.user.is_authenticated %}
|
|
User: {{ request.user }}
|
|
{% else %}
|
|
CID: {{ cid }}
|
|
{% endif %}
|
|
</span>
|
|
<h2 class="h5 mt-1">{{ exam }}: Question <small class="text-muted">[<span id="question-number">{{ pos|add:1 }}</span>/<span id="exam-length">{{ exam_length }}</span>]</small></h2>
|
|
|
|
<div class="">
|
|
{% include "exam_clock.html" %}
|
|
</div>
|
|
</div>
|
|
|
|
{% if exam.publish_results %}
|
|
<div class="alert alert-primary review-mode-alert" role="alert">
|
|
Exam is in review mode. Add question feedback <a href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='sbas' pk=question.pk %}')">here</a>.
|
|
</div>
|
|
{% elif cid_user_exam.completed %}
|
|
<div class="alert alert-primary review-mode-alert" role="alert">Exam completed.</div>
|
|
{% endif %}
|
|
|
|
<div class="row flex-wrap">
|
|
{# Layout change: make both columns full-width so the right panel is not a sidebar #}
|
|
<div class="col-12 order-1 mb-3">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div>
|
|
<p><span id="question-stem"></span></p>
|
|
</div>
|
|
|
|
<div id="question-fragment"
|
|
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">
|
|
<!-- HTMX will load the per-question form fragment here -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-12 order-2 mb-3">
|
|
{% include "sbas/exam_take_help.html" %}
|
|
|
|
<div class="card mt-3">
|
|
<div class="card-body">
|
|
<h6 class="card-title">Questions</h6>
|
|
<p class="small text-muted mb-2">Tap a number to jump to that question.</p>
|
|
|
|
<!-- Collapsible for small screens -->
|
|
<div class="d-md-none mb-2">
|
|
<button class="btn btn-sm btn-outline-secondary w-100" type="button" data-bs-toggle="collapse" data-bs-target="#menu-list" aria-expanded="false" aria-controls="menu-list">
|
|
Show questions
|
|
</button>
|
|
</div>
|
|
|
|
<div id="menu-list" class="collapse d-md-block">
|
|
<div class="d-flex flex-wrap gap-1" id="menu-list" role="toolbar" aria-label="Question navigation">
|
|
<!-- Buttons injected by JS -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block js %}
|
|
<script>
|
|
$(document).ready(() => {
|
|
/* beautify ignore:start */
|
|
// initialize global question state from server
|
|
window.examQuestionAnswered = {{ answered_json|default:'null'|safe }};
|
|
window.examQuestionFlagged = {{ flagged_json|default:'null'|safe }};
|
|
window.updateQuestionMenu = function(){
|
|
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);
|
|
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); }
|
|
};
|
|
{% if not exam.publish_results and not cid_user_exam.completed %}
|
|
|
|
let time_limit = '{{exam.time_limit}}'
|
|
if (time_limit != "None") {
|
|
let end_time = new Date({{cid_user_exam.start_time|date:"U"}}*1000+parseInt('{{exam.time_limit}}')*1000);
|
|
|
|
initializeClock("clockdiv", end_time);
|
|
}
|
|
|
|
// Delegate clicks so dynamically-loaded fragments are handled
|
|
$(document).on('click', 'ul.sba-answer-list li', function(e){
|
|
const el = this;
|
|
try{ $("#id_answer").val(el.dataset.ans); }catch(err){}
|
|
$("ul.sba-answer-list li").removeClass("selected");
|
|
$(el).addClass("selected")
|
|
});
|
|
{% elif exam.publish_results %}
|
|
$("ul.sba-answer-list li[data-ans='{{question.best_answer}}']").addClass("correct");
|
|
{% endif %}
|
|
|
|
{% if saved_answer %}
|
|
$("ul.sba-answer-list li[data-ans='{{saved_answer}}']").addClass("selected"); {% endif %}
|
|
|
|
for (let i = 0; i < {{ exam_length }}; i++) {
|
|
// Create Bootstrap-styled buttons and append into the flex-wrap container
|
|
const qbutton = document.createElement('button');
|
|
qbutton.type = 'button';
|
|
qbutton.className = 'btn btn-sm btn-outline-secondary question-menu-item';
|
|
qbutton.name = `goto-${i}`;
|
|
qbutton.dataset.qn = i;
|
|
qbutton.textContent = (i+1).toString();
|
|
if (i == {{pos}}) qbutton.classList.add('current-question');
|
|
document.getElementById('menu-list').appendChild(qbutton);
|
|
}
|
|
|
|
document.getElementById('menu-list').addEventListener('click', function(e){
|
|
const btn = e.target.closest('.question-menu-item');
|
|
if (!btn) return;
|
|
document.getElementById('goto-button').value = btn.dataset.qn;
|
|
document.getElementById('goto-button').click();
|
|
});
|
|
|
|
if(typeof window.updateQuestionMenu === 'function') window.updateQuestionMenu();
|
|
|
|
|
|
})
|
|
</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>
|
|
// Update flagged state when flag button is swapped via HTMX
|
|
document.addEventListener('htmx:afterSwap', function(evt){
|
|
try{
|
|
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>
|
|
<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 %}
|
|
|
|
{% comment %} Loading styles merged into main CSS block below to avoid duplicate block tags {% endcomment %}
|
|
{% block 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 {
|
|
display: none;
|
|
}
|
|
|
|
/* SBA answer list styled similarly to physics answers */
|
|
.sba-answer-list { list-style: none; padding: 0; margin: 0; }
|
|
.sba-answer-list li {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem 1rem;
|
|
border: 1px solid var(--card-border);
|
|
border-radius: 0.5rem;
|
|
background: var(--card-bg);
|
|
box-shadow: var(--shadow);
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
}
|
|
.sba-answer-list li + li { margin-top: 0.75rem; }
|
|
|
|
.sba-answer-list li .question-text { margin: 0; }
|
|
|
|
/* Selected state */
|
|
.sba-answer-list li.selected { background-color: rgba(13,110,253,0.06); border-color: rgba(13,110,253,0.18); }
|
|
|
|
/* Small helper for flagged/answered markers in the menu */
|
|
.question-flag { margin-left: 0.5rem; color: var(--muted); }
|
|
|
|
.selected {
|
|
border: 1px solid purple;
|
|
}
|
|
/* radio-like selection indicator */
|
|
.sba-answer-list li { position: relative; }
|
|
.sba-answer-list li::before {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 1rem; height: 1rem;
|
|
border-radius: 50%;
|
|
border: 2px solid var(--muted);
|
|
margin-right: 0.5rem;
|
|
flex: 0 0 auto;
|
|
}
|
|
.sba-answer-list li.selected::before {
|
|
background: var(--primary);
|
|
border-color: var(--primary);
|
|
box-shadow: 0 0 0 3px rgba(110,168,255,0.06);
|
|
}
|
|
.sba-answer-list li:hover::after {
|
|
content: "Click to select";
|
|
position: absolute; right: 0.75rem; top: 0.6rem; color: var(--muted); font-size: 0.8rem;
|
|
}
|
|
.sba-answer-list li.selected:hover::after { content: "Chosen"; }
|
|
|
|
/* Make question nav buttons consistent and allow wrapping on small screens */
|
|
.question-menu-item {
|
|
white-space: nowrap;
|
|
flex: 0 0 auto;
|
|
padding: .1rem .1rem;
|
|
}
|
|
|
|
/* Disable sticky behaviour on narrow viewports so the sidebar stacks naturally */
|
|
@media (max-width: 767.98px) {
|
|
.card.sticky-top {
|
|
position: static !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>
|
|
{% endblock %} |