Spaces:
Sleeping
Sleeping
| {% 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) ; | |
| border-color: var(--accent-warning) ; | |
| color: #000 ; | |
| 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> | |
| </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 %} | |