Add loading spinner and blur effect for question fragment during HTMX requests; optimize answered and flagged state queries
This commit is contained in:
@@ -76,6 +76,55 @@
|
|||||||
}catch(e){ console && console.error && console.error(e); }
|
}catch(e){ console && console.error && console.error(e); }
|
||||||
};
|
};
|
||||||
</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);}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
<script>
|
||||||
// Also update after HTMX settle to ensure DOM changes are applied
|
// Also update after HTMX settle to ensure DOM changes are applied
|
||||||
document.body.addEventListener('htmx:afterSettle', function(){
|
document.body.addEventListener('htmx:afterSettle', function(){
|
||||||
@@ -271,9 +320,21 @@
|
|||||||
color: #ffc107;
|
color: #ffc107;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
/* Loading blur + spinner for question fragment */
|
||||||
|
#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(255,255,255,0.2); border-top-color: var(--primary);
|
||||||
|
animation: spin 800ms linear infinite; z-index: 1200;
|
||||||
|
}
|
||||||
|
@keyframes spin { from { transform: translate(-50%,-50%) rotate(0deg); } to { transform: translate(-50%,-50%) rotate(360deg); } }
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block head_js %}{% endblock %}
|
||||||
|
|
||||||
{% block js %}
|
{% block js %}
|
||||||
<script>
|
<script>
|
||||||
/* HTMX swap helpers: preserve scroll and container height to avoid page jumping
|
/* HTMX swap helpers: preserve scroll and container height to avoid page jumping
|
||||||
|
|||||||
+40
-38
@@ -179,29 +179,28 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
|
|||||||
else:
|
else:
|
||||||
answers = UserAnswer.objects.filter(user=request.user, exam=exam)
|
answers = UserAnswer.objects.filter(user=request.user, exam=exam)
|
||||||
|
|
||||||
answer_question_map = {}
|
# Map answers by question id for quick lookup
|
||||||
for ans in answers:
|
answer_question_map = {ans.question_id: ans for ans in answers}
|
||||||
answer_question_map[ans.question] = ans
|
|
||||||
|
# Prepare bulk flagged set to avoid per-question queries
|
||||||
|
q_ids = [q.pk for q in questions]
|
||||||
|
try:
|
||||||
|
ct = ContentType.objects.get_for_model(Question)
|
||||||
|
if cid is not None:
|
||||||
|
flags_qs = Flag.objects.filter(content_type=ct, object_id__in=q_ids, cid_user__cid=cid)
|
||||||
|
else:
|
||||||
|
flags_qs = Flag.objects.filter(content_type=ct, object_id__in=q_ids, user=request.user)
|
||||||
|
flagged_set = set(flags_qs.values_list('object_id', flat=True))
|
||||||
|
except Exception:
|
||||||
|
flagged_set = set()
|
||||||
|
|
||||||
question_answer_tuples = []
|
question_answer_tuples = []
|
||||||
answer_count = 0
|
answer_count = 0
|
||||||
for q in questions:
|
for q in questions:
|
||||||
if q in answer_question_map:
|
ans = answer_question_map.get(q.pk)
|
||||||
ans = answer_question_map[q]
|
if ans is not None:
|
||||||
answer_count += 1
|
answer_count += 1
|
||||||
else:
|
flagged = q.pk in flagged_set
|
||||||
ans = None
|
|
||||||
# compute flagged state for this question+actor
|
|
||||||
try:
|
|
||||||
ct = ContentType.objects.get_for_model(q)
|
|
||||||
flags_qs = Flag.objects.filter(content_type=ct, object_id=q.pk)
|
|
||||||
if cid is not None:
|
|
||||||
flags_qs = flags_qs.filter(cid_user__cid=cid)
|
|
||||||
else:
|
|
||||||
flags_qs = flags_qs.filter(user=request.user)
|
|
||||||
flagged = flags_qs.exists()
|
|
||||||
except Exception:
|
|
||||||
flagged = False
|
|
||||||
question_answer_tuples.append((q, ans, flagged))
|
question_answer_tuples.append((q, ans, flagged))
|
||||||
|
|
||||||
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
|
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
|
||||||
@@ -375,6 +374,15 @@ def exam_take(request, pk: int, sk: int, cid: str | None = None, passcode: str |
|
|||||||
except Exception:
|
except Exception:
|
||||||
flagged = False
|
flagged = False
|
||||||
|
|
||||||
|
# Precompute answered and flagged sets in bulk to avoid repeated queries
|
||||||
|
q_ids = [q.pk for q in questions]
|
||||||
|
if cid is not None:
|
||||||
|
answered_set = set(UserAnswer.objects.filter(cid=cid, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
|
||||||
|
flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, cid_user__cid=cid).values_list('object_id', flat=True))
|
||||||
|
else:
|
||||||
|
answered_set = set(UserAnswer.objects.filter(user=request.user, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
|
||||||
|
flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, user=request.user).values_list('object_id', flat=True))
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"physics/exam_take.html",
|
"physics/exam_take.html",
|
||||||
@@ -392,16 +400,9 @@ def exam_take(request, pk: int, sk: int, cid: str | None = None, passcode: str |
|
|||||||
"cid_user_exam": cid_user_exam,
|
"cid_user_exam": cid_user_exam,
|
||||||
"flagged": flagged,
|
"flagged": flagged,
|
||||||
# minimal per-question status for client-side menu: answered/flagged lists
|
# minimal per-question status for client-side menu: answered/flagged lists
|
||||||
"answered_json": json.dumps([
|
# compute answered/flagged sets in bulk to avoid N+1 queries
|
||||||
True if (question.cid_user_answers.filter(cid=cid, exam=exam).exists() if cid is not None else question.cid_user_answers.filter(user=request.user, exam=exam).exists()) else False
|
"answered_json": json.dumps([q.pk in answered_set for q in questions]),
|
||||||
for question in questions
|
"flagged_json": json.dumps([q.pk in flagged_set for q in questions]),
|
||||||
]),
|
|
||||||
"flagged_json": json.dumps([
|
|
||||||
(Flag.objects.filter(content_type=ContentType.objects.get_for_model(question), object_id=question.pk).filter(cid_user__cid=cid).exists()
|
|
||||||
if cid is not None
|
|
||||||
else Flag.objects.filter(content_type=ContentType.objects.get_for_model(question), object_id=question.pk).filter(user=request.user).exists())
|
|
||||||
for question in questions
|
|
||||||
]),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -470,6 +471,15 @@ def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passco
|
|||||||
if sk == exam_length - 1:
|
if sk == exam_length - 1:
|
||||||
next = False
|
next = False
|
||||||
|
|
||||||
|
# Bulk compute answered and flagged sets for the fragment
|
||||||
|
q_ids = [q.pk for q in questions]
|
||||||
|
if cid is not None:
|
||||||
|
frag_answered_set = set(UserAnswer.objects.filter(cid=cid, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
|
||||||
|
frag_flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, cid_user__cid=cid).values_list('object_id', flat=True))
|
||||||
|
else:
|
||||||
|
frag_answered_set = set(UserAnswer.objects.filter(user=request.user, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
|
||||||
|
frag_flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, user=request.user).values_list('object_id', flat=True))
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"physics/partials/exam_take_fragment.html",
|
"physics/partials/exam_take_fragment.html",
|
||||||
@@ -485,16 +495,8 @@ def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passco
|
|||||||
"saved_answer": saved_answer,
|
"saved_answer": saved_answer,
|
||||||
"answer": answer,
|
"answer": answer,
|
||||||
"flagged": flagged,
|
"flagged": flagged,
|
||||||
"answered_json": json.dumps([
|
"answered_json": json.dumps([q.pk in frag_answered_set for q in questions]),
|
||||||
True if (q.cid_user_answers.filter(cid=cid, exam=exam).exists() if cid is not None else q.cid_user_answers.filter(user=request.user, exam=exam).exists()) else False
|
"flagged_json": json.dumps([q.pk in frag_flagged_set for q in questions]),
|
||||||
for q in questions
|
|
||||||
]),
|
|
||||||
"flagged_json": json.dumps([
|
|
||||||
(Flag.objects.filter(content_type=ContentType.objects.get_for_model(q), object_id=q.pk).filter(cid_user__cid=cid).exists()
|
|
||||||
if cid is not None
|
|
||||||
else Flag.objects.filter(content_type=ContentType.objects.get_for_model(q), object_id=q.pk).filter(user=request.user).exists())
|
|
||||||
for q in questions
|
|
||||||
]),
|
|
||||||
"passcode": passcode,
|
"passcode": passcode,
|
||||||
"cid_user_exam": cid_user_exam,
|
"cid_user_exam": cid_user_exam,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user