Report-Generator / templates /quiz_v2.html
t
style: unify UI/UX across question entry and quiz templates
b08752b
Raw
History Blame
23.9 kB
{% extends "base.html" %}
{% block title %}Quiz Mode{% endblock %}
{% block head %}
<style>
/* === UNIFIED DESIGN SYSTEM === */
:root {
--bg-dark: #212529;
--bg-card: #2b3035;
--bg-elevated: #343a40;
--bg-hover: #3d444b;
--border-subtle: #495057;
--border-muted: #6c757d;
--text-primary: #e9ecef;
--text-muted: #adb5bd;
--accent-primary: #0d6efd;
--accent-info: #0dcaf0;
--accent-success: #198754;
--accent-warning: #ffc107;
--accent-danger: #dc3545;
--transition-fast: 0.15s ease;
--transition-normal: 0.25s ease;
--shadow-sm: 0 2px 4px rgba(0,0,0,0.3);
--shadow-md: 0 4px 12px rgba(0,0,0,0.4);
}
/* --- FORCE NO SCROLL ON PAGE --- */
body, html {
overflow: hidden;
height: 100%;
background-color: var(--bg-dark);
}
/* --- MAIN CONTAINER --- */
.quiz-container {
height: 88dvh;
max-height: 88dvh;
width: 100%;
display: flex;
flex-direction: column;
margin: 0 auto;
background-color: var(--bg-card);
}
/* --- 1. TOP PROGRESS --- */
.progress-container {
height: 5px;
background-color: var(--bg-elevated);
flex-shrink: 0;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-info));
width: 0%;
transition: width var(--transition-normal);
}
/* --- 2. MIDDLE IMAGE AREA --- */
.question-display {
flex: 1;
min-height: 0;
display: flex;
justify-content: center;
align-items: center;
padding: 5px;
position: relative;
overflow: hidden;
}
.question-image {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
border-radius: 8px;
opacity: 0;
transition: opacity var(--transition-normal);
}
.question-image.loaded {
opacity: 1;
}
/* Loading spinner */
.image-loading {
position: absolute;
display: none;
color: var(--text-muted);
}
.image-loading.show {
display: block;
}
/* --- 3. BOTTOM CONTROLS --- */
.quiz-controls {
flex-shrink: 0;
height: 70px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 0 15px;
background: linear-gradient(180deg, var(--bg-card), var(--bg-dark));
border-top: 1px solid var(--border-subtle);
z-index: 50;
}
/* --- DETAILS PANEL --- */
.details-spoiler {
display: none;
background-color: var(--bg-elevated);
border-top: 1px solid var(--border-muted);
max-height: 50%;
overflow-y: auto;
padding: 15px;
font-size: 0.9rem;
color: var(--text-primary);
}
/* --- UNIFIED NOTE SECTION --- */
.note-section {
background: linear-gradient(135deg, rgba(13, 202, 240, 0.08), rgba(13, 202, 240, 0.15));
border: 1px solid rgba(13, 202, 240, 0.3);
border-radius: 10px;
padding: 12px;
margin-top: 10px;
transition: all var(--transition-fast);
}
.note-section:hover {
border-color: var(--accent-info);
box-shadow: 0 0 12px rgba(13, 202, 240, 0.15);
}
.note-section h6 {
color: var(--accent-info);
margin-bottom: 10px;
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.note-section .note-thumb {
max-height: 200px;
width: 100%;
object-fit: contain;
border-radius: 8px;
border: 2px solid var(--accent-info);
background: #fff;
transition: transform var(--transition-fast);
}
.note-section .note-thumb:hover {
transform: scale(1.02);
}
/* --- UNIFIED BUTTON STYLES --- */
.btn-pill {
border-radius: 50px;
font-weight: 500;
transition: all var(--transition-fast);
}
.btn-pill:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.btn-pill:active {
transform: translateY(0);
}
/* --- OVERLAYS --- */
.answer-overlay {
position: absolute;
top: 10px;
padding: 6px 12px;
border-radius: 20px;
color: white;
font-weight: 600;
font-size: 0.8rem;
z-index: 10;
display: none;
backdrop-filter: blur(4px);
box-shadow: var(--shadow-md);
transition: all var(--transition-fast);
}
#your-answer-overlay { left: 10px; background: rgba(13, 110, 253, 0.9); }
#correct-answer-overlay { right: 10px; background: rgba(25, 135, 84, 0.9); }
/* Bookmark button states */
.bookmark-btn {
transition: all var(--transition-fast);
}
.bookmark-btn.bookmarked {
background-color: var(--accent-warning) !important;
border-color: var(--accent-warning) !important;
color: #000 !important;
box-shadow: 0 0 10px rgba(255, 193, 7, 0.4);
}
.bookmark-btn.bookmarked i::before {
content: "\F147"; /* bi-bookmark-fill */
}
/* Collection list in modal */
.collection-item {
cursor: pointer;
transition: all var(--transition-fast);
border-radius: 6px;
}
.collection-item:hover {
background-color: var(--bg-hover);
transform: translateX(2px);
}
.collection-item.selected {
background-color: var(--accent-primary);
color: white;
}
</style>
{% endblock %}
{% block content %}
<div class="quiz-container">
<!-- 1. Progress -->
<div class="progress-container">
<div class="progress-bar" id="progress-bar"></div>
</div>
<!-- 2. Image (Will auto-resize to fit remaining space) -->
<div class="question-display">
<div class="image-loading" id="image-loading">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<img src="" id="question-image" class="question-image" alt="Question">
<div id="your-answer-overlay" class="answer-overlay"></div>
<div id="correct-answer-overlay" class="answer-overlay"></div>
</div>
<!-- 3. Details (Hidden by default) -->
<div class="details-spoiler" id="details-spoiler">
<div id="details-info">
<h6 class="text-white mb-2"><i class="bi bi-info-circle me-1"></i>Details</h6>
<div id="details-content"></div>
</div>
<div id="note-section" class="note-section" style="display: none;">
<h6><i class="bi bi-journal-text me-1"></i>Revision Notes</h6>
<img id="details-note-img" src="" class="note-thumb" alt="Revision Note">
</div>
</div>
<!-- 4. Controls -->
<div class="quiz-controls">
<!-- Left -->
<div class="d-flex gap-2">
<button id="prev-btn" class="btn btn-secondary btn-sm btn-pill">
<i class="bi bi-arrow-left"></i> Prev
</button>
<button id="bookmark-btn" class="btn btn-outline-warning btn-sm btn-pill bookmark-btn" title="Add to Collection">
<i class="bi bi-bookmark"></i>
</button>
</div>
<!-- Center -->
<div class="text-center">
<button id="spoiler-btn" class="btn btn-warning btn-sm btn-pill px-3">Check Answer</button>
<div id="question-counter" style="font-size: 11px; color: #adb5bd; margin-top: 2px;"></div>
</div>
<!-- Right -->
<div class="text-end">
<button id="next-btn" class="btn btn-primary btn-sm btn-pill">
Next <i class="bi bi-arrow-right"></i>
</button>
</div>
</div>
</div>
<!-- Bookmark Collection Modal -->
<div class="modal fade" id="bookmarkModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark text-white">
<div class="modal-header border-secondary" style="background: linear-gradient(180deg, var(--bg-card, #2b3035), var(--bg-dark, #212529));">
<h5 class="modal-title"><i class="bi bi-bookmark me-2"></i>Add to Collection</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="collections-list" class="mb-3">
<div class="text-muted text-center py-3">Loading collections...</div>
</div>
<div class="border-top border-secondary pt-3">
<div class="input-group">
<input type="text" id="new-collection-name" class="form-control bg-secondary text-white border-secondary" placeholder="New collection name...">
<button class="btn btn-success btn-pill" id="create-collection-btn">
<i class="bi bi-plus-lg"></i> Create
</button>
</div>
</div>
</div>
<div class="modal-footer border-secondary">
<span id="bookmark-status" class="text-muted small me-auto"></span>
<button type="button" class="btn btn-secondary btn-pill" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
const questions = {{ questions | tojson | safe }};
</script>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', () => {
let currentQuestionIndex = 0;
const img = document.getElementById('question-image');
const imgLoading = document.getElementById('image-loading');
const counter = document.getElementById('question-counter');
const pBar = document.getElementById('progress-bar');
const spoiler = document.getElementById('details-spoiler');
const spoilerBtn = document.getElementById('spoiler-btn');
const yourOv = document.getElementById('your-answer-overlay');
const corrOv = document.getElementById('correct-answer-overlay');
const bookmarkBtn = document.getElementById('bookmark-btn');
// Lazy loading: preload adjacent images
const imageCache = new Map();
let collections = [];
let questionBookmarks = new Map(); // questionId -> [collectionIds]
function preloadImage(path) {
if (imageCache.has(path)) return imageCache.get(path);
const promise = new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(path);
img.onerror = reject;
img.src = path;
});
imageCache.set(path, promise);
return promise;
}
function getImagePath(q) {
let path = q.image_path;
// If path already starts with /, it's already an absolute URL path
if (path.startsWith('/')) {
// Convert /processed/ or /tmp/ to /neetprep/processed/ or /neetprep/tmp/
if (path.startsWith('/processed/')) return `/neetprep${path}`;
if (path.startsWith('/tmp/')) return `/neetprep${path}`;
return path;
}
// Handle relative paths (legacy)
if(path.includes('/processed/')) path = `/neetprep/processed/${path.split('/processed/')[1]}`;
else if(path.includes('/tmp/')) path = `/neetprep/tmp/${path.split('/tmp/')[1]}`;
return path;
}
// Preload images around current index
function preloadAround(index) {
const toPreload = [index - 1, index, index + 1, index + 2, index + 3];
toPreload.forEach(i => {
if (i >= 0 && i < questions.length) {
preloadImage(getImagePath(questions[i]));
}
});
}
function loadQ(i) {
if(i < 0 || i >= questions.length) return;
currentQuestionIndex = i;
const q = questions[i];
const path = getImagePath(q);
// Show loading, hide image
img.classList.remove('loaded');
imgLoading.classList.add('show');
// Check cache or load
if (imageCache.has(path)) {
imageCache.get(path).then(() => {
img.src = path;
img.classList.add('loaded');
imgLoading.classList.remove('show');
});
} else {
preloadImage(path).then(() => {
img.src = path;
img.classList.add('loaded');
imgLoading.classList.remove('show');
}).catch(() => {
img.src = path; // Try anyway
imgLoading.classList.remove('show');
});
}
// Preload adjacent images
preloadAround(i);
// UI Updates
counter.textContent = `${i+1} / ${questions.length}`;
pBar.style.width = `${((i+1)/questions.length)*100}%`;
// Details
const d = q.details;
let h = `<strong>Subject:</strong> ${d.subject || 'N/A'} <br> <strong>Topic:</strong> ${d.topic || 'N/A'}`;
if(d.options && d.options.length) { h += '<ol type="A" style="padding-left:15px; margin:5px 0;">'; d.options.forEach(o=>h+=`<li>${o}</li>`); h+='</ol>'; }
document.getElementById('details-content').innerHTML = h;
// Set revision note if available
const noteSection = document.getElementById('note-section');
const noteImg = document.getElementById('details-note-img');
if (d.note_filename) {
noteImg.src = `/neetprep/processed/${d.note_filename}`;
noteSection.style.display = 'block';
} else {
noteSection.style.display = 'none';
}
// Overlays
const fmt = v => typeof v==='number'?String.fromCharCode(65+v):v;
yourOv.textContent = `You: ${fmt(d.user_answer_index)}`;
corrOv.textContent = `Ans: ${fmt(d.correct_answer_index)}`;
// Reset
spoiler.style.display = 'none';
yourOv.style.display = 'none';
corrOv.style.display = 'none';
spoilerBtn.textContent = 'Check Answer';
spoilerBtn.className = 'btn btn-warning btn-sm btn-pill px-3';
document.getElementById('prev-btn').disabled = i === 0;
document.getElementById('next-btn').disabled = i === questions.length - 1;
// Fetch and update bookmark button state
const qId = q.details?.id;
if (qId && !questionBookmarks.has(qId)) {
// Fetch bookmark status if not already cached
fetchQuestionBookmarks(qId);
} else {
updateBookmarkButton();
}
}
function updateBookmarkButton() {
const q = questions[currentQuestionIndex];
const qId = q.details?.id;
if (qId && questionBookmarks.has(qId) && questionBookmarks.get(qId).length > 0) {
bookmarkBtn.classList.add('bookmarked');
bookmarkBtn.title = 'Bookmarked';
} else {
bookmarkBtn.classList.remove('bookmarked');
bookmarkBtn.title = 'Add to Collection';
}
}
// Fetch collections
async function fetchCollections() {
try {
const res = await fetch('/neetprep/collections');
const data = await res.json();
if (data.success) {
collections = data.collections;
}
} catch (e) {
console.error('Failed to fetch collections:', e);
}
}
// Fetch which collections the current question is in
async function fetchQuestionBookmarks(questionId) {
if (!questionId) return;
try {
const res = await fetch(`/neetprep/question/${questionId}/collections`);
const data = await res.json();
if (data.success) {
questionBookmarks.set(questionId, data.collections.map(c => c.id));
updateBookmarkButton();
}
} catch (e) {
console.error('Failed to fetch question bookmarks:', e);
}
}
// Batch fetch bookmark status for all questions
async function fetchAllBookmarkStatuses() {
const questionIds = questions
.map(q => q.details?.id)
.filter(id => id != null);
if (questionIds.length === 0) return;
try {
const res = await fetch('/neetprep/bookmarks/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question_ids: questionIds })
});
const data = await res.json();
if (data.success && data.bookmarks) {
// Populate the questionBookmarks map
for (const [qId, collectionIds] of Object.entries(data.bookmarks)) {
questionBookmarks.set(qId, collectionIds);
}
updateBookmarkButton();
}
} catch (e) {
console.error('Failed to batch fetch bookmarks:', e);
}
}
function renderCollectionsList() {
const list = document.getElementById('collections-list');
const q = questions[currentQuestionIndex];
const qId = q.details?.id;
const bookmarkedIn = questionBookmarks.get(qId) || [];
if (collections.length === 0) {
list.innerHTML = '<div class="text-muted text-center py-3">No collections yet. Create one below!</div>';
return;
}
list.innerHTML = collections.map(c => {
const isBookmarked = bookmarkedIn.includes(c.id);
return `
<div class="collection-item d-flex justify-content-between align-items-center p-2 rounded mb-1 ${isBookmarked ? 'selected' : ''}"
data-id="${c.id}">
<span>
<i class="bi bi-folder${isBookmarked ? '-fill' : ''} me-2"></i>${c.name}
<small class="text-muted">(${c.question_count || 0})</small>
</span>
<span>
${isBookmarked
? '<i class="bi bi-check-circle-fill text-success"></i>'
: '<i class="bi bi-plus-circle text-secondary"></i>'}
</span>
</div>
`;
}).join('');
// Add click handlers
list.querySelectorAll('.collection-item').forEach(item => {
item.onclick = async () => {
const collectionId = item.dataset.id;
const isBookmarked = bookmarkedIn.includes(collectionId);
const questionType = q.details?.source || 'neetprep';
try {
if (isBookmarked) {
// Remove bookmark
await fetch('/neetprep/bookmark', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question_id: qId, session_id: collectionId, question_type: questionType })
});
questionBookmarks.set(qId, bookmarkedIn.filter(id => id !== collectionId));
document.getElementById('bookmark-status').textContent = 'Removed from collection';
} else {
// Add bookmark
await fetch('/neetprep/bookmark', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question_id: qId, session_id: collectionId, question_type: questionType })
});
questionBookmarks.set(qId, [...bookmarkedIn, collectionId]);
document.getElementById('bookmark-status').textContent = 'Added to collection';
}
// Refresh collections count and UI
await fetchCollections();
renderCollectionsList();
updateBookmarkButton();
} catch (e) {
console.error('Bookmark operation failed:', e);
document.getElementById('bookmark-status').textContent = 'Error: ' + e.message;
}
};
});
}
// Create new collection
document.getElementById('create-collection-btn').onclick = async () => {
const nameInput = document.getElementById('new-collection-name');
const name = nameInput.value.trim();
if (!name) return;
try {
const res = await fetch('/neetprep/collections/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
const data = await res.json();
if (data.success) {
nameInput.value = '';
await fetchCollections();
renderCollectionsList();
document.getElementById('bookmark-status').textContent = `Created "${name}"`;
}
} catch (e) {
console.error('Failed to create collection:', e);
}
};
// Bookmark button click
bookmarkBtn.onclick = async () => {
const q = questions[currentQuestionIndex];
const qId = q.details?.id;
if (!qId) {
alert('This question cannot be bookmarked (no ID)');
return;
}
// Fetch current bookmarks for this question
await fetchQuestionBookmarks(qId);
await fetchCollections();
renderCollectionsList();
const modal = new bootstrap.Modal(document.getElementById('bookmarkModal'));
modal.show();
};
spoilerBtn.onclick = () => {
if(spoiler.style.display === 'none') {
spoiler.style.display = 'block';
yourOv.style.display = 'block';
corrOv.style.display = 'block';
spoilerBtn.textContent = 'Hide';
spoilerBtn.className = 'btn btn-outline-light btn-sm btn-pill px-3';
} else {
spoiler.style.display = 'none';
yourOv.style.display = 'none';
corrOv.style.display = 'none';
spoilerBtn.textContent = 'Check Answer';
spoilerBtn.className = 'btn btn-warning btn-sm btn-pill px-3';
}
};
document.getElementById('prev-btn').onclick = () => loadQ(currentQuestionIndex - 1);
document.getElementById('next-btn').onclick = () => loadQ(currentQuestionIndex + 1);
document.addEventListener('keydown', e => {
// Ignore if typing in input
if (e.target.tagName === 'INPUT') return;
if(e.key === 'ArrowLeft') loadQ(currentQuestionIndex - 1);
if(e.key === 'ArrowRight') loadQ(currentQuestionIndex + 1);
if(e.key === ' ' || e.key === 'Enter' || e.key === 'c') { e.preventDefault(); spoilerBtn.click(); }
if(e.key === 'b') bookmarkBtn.click();
});
// Initial load
if(questions.length) {
fetchCollections();
// Batch fetch all bookmark statuses, then load first question
fetchAllBookmarkStatuses().then(() => {
// After batch fetch, ensure first question shows correct status
updateBookmarkButton();
});
loadQ(0);
// Prefetch first few questions on initial load
for (let i = 0; i < Math.min(5, questions.length); i++) {
preloadImage(getImagePath(questions[i]));
}
}
});
</script>
{% endblock %}