diff --git a/content/.obsidian/workspace.json b/content/.obsidian/workspace.json index 1215109..ff4dfec 100644 --- a/content/.obsidian/workspace.json +++ b/content/.obsidian/workspace.json @@ -70,7 +70,7 @@ "state": { "type": "search", "state": { - "query": "spoiler-", + "query": "frågetyp/matching", "matchingCase": false, "explainSearch": false, "collapseAll": false, @@ -209,6 +209,8 @@ }, "active": "b6de1b6650c09ff3", "lastOpenFiles": [ + "Anatomi & Histologi 2/Öga anatomi.md", + "Anatomi & Histologi 2/Schema.md", "Anatomi & Histologi 2/Statistik.md", "Anatomi & Histologi 2/1 Öga anatomi/Slides.pdf.pdf", "Anatomi & Histologi 2/1 Öga anatomi/Slides.md", @@ -221,7 +223,6 @@ "Anatomi & Histologi 2/2 Öra anatomi/Video 3.md", "Anatomi & Histologi 2/2 Öra anatomi/Video 2.md", "Anatomi & Histologi 2/2 Öra anatomi/Video 1.md", - "Anatomi & Histologi 2/Schema.md", "Anatomi & Histologi 2/1 Öga anatomi/Provfrågor.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/21.md", "Anatomi & Histologi 2/Gamla tentor/2025-01-15/17.md", diff --git a/content/Anatomi & Histologi 2/Öga anatomi.md b/content/Anatomi & Histologi 2/Öga anatomi.md new file mode 100644 index 0000000..e69de29 diff --git a/quiz/MATCHING_FORMAT.md b/quiz/MATCHING_FORMAT.md new file mode 100644 index 0000000..1ee176a --- /dev/null +++ b/quiz/MATCHING_FORMAT.md @@ -0,0 +1,18 @@ +# Matching Questions Format Analysis + +Based on reviewing the 17 matching questions: + +## Key Finding: +Only **1 question has an answer** (2023-05-31/3.md), the rest have TODO. + +**That question uses this format:** +- Two separate bullet lists +- Answer: "ItemName: MatchName" format + +## Proposed Implementation: +1. Support two-list format (most flexible) +2. Parse answer as "Item: Match" pairs +3. Store as JSON with 0-indexed pairs +4. Render as n×n table with radio buttons + +## Next: Implement based on this one working example. diff --git a/quiz/db.sqlite3 b/quiz/db.sqlite3 index f32bd2b..82d67a6 100644 Binary files a/quiz/db.sqlite3 and b/quiz/db.sqlite3 differ diff --git a/quiz/db.sqlite3-shm b/quiz/db.sqlite3-shm index 628a2db..9520413 100644 Binary files a/quiz/db.sqlite3-shm and b/quiz/db.sqlite3-shm differ diff --git a/quiz/db.sqlite3-wal b/quiz/db.sqlite3-wal index 081642a..55aabe7 100644 Binary files a/quiz/db.sqlite3-wal and b/quiz/db.sqlite3-wal differ diff --git a/quiz/quiz/migrations/0010_add_matching_question_fields.py b/quiz/quiz/migrations/0010_add_matching_question_fields.py new file mode 100644 index 0000000..178b480 --- /dev/null +++ b/quiz/quiz/migrations/0010_add_matching_question_fields.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0 on 2025-12-22 14:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('quiz', '0009_quizresult_difficulty'), + ] + + operations = [ + migrations.AddField( + model_name='question', + name='matching_data', + field=models.JSONField(blank=True, help_text='JSON data for matching questions: {left_items: [...], top_items: [...], correct_pairs: [[0,1], [1,2], ...]}', null=True), + ), + migrations.AddField( + model_name='question', + name='question_type', + field=models.CharField(choices=[('mcq', 'Multiple Choice'), ('scq', 'Single Choice'), ('matching', 'Matching'), ('textalternativ', 'Text Alternative'), ('textfält', 'Text Field')], default='mcq', max_length=20), + ), + ] diff --git a/quiz/quiz/models.py b/quiz/quiz/models.py index 1bbd2cc..3854eaf 100644 --- a/quiz/quiz/models.py +++ b/quiz/quiz/models.py @@ -47,6 +47,26 @@ class Question(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) tags = models.ManyToManyField('Tag', blank=True, related_name='questions') + + # Question type field + question_type = models.CharField( + max_length=20, + default='mcq', + choices=[ + ('mcq', 'Multiple Choice'), + ('scq', 'Single Choice'), + ('matching', 'Matching'), + ('textalternativ', 'Text Alternative'), + ('textfält', 'Text Field'), + ] + ) + + # JSON field for matching questions + matching_data = models.JSONField( + null=True, + blank=True, + help_text="JSON data for matching questions: {left_items: [...], top_items: [...], correct_pairs: [[0,1], [1,2], ...]}" + ) def __str__(self): return self.text[:50] diff --git a/quiz/quiz/tests/__init__.py b/quiz/quiz/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/quiz/quiz/tests/test_matching_parser.py b/quiz/quiz/tests/test_matching_parser.py new file mode 100644 index 0000000..65e5be0 --- /dev/null +++ b/quiz/quiz/tests/test_matching_parser.py @@ -0,0 +1,128 @@ +from django.test import TestCase +from pathlib import Path +from quiz.utils.importer import parse_matching_question + + +class MatchingQuestionParserTests(TestCase): + """Tests for matching question parser""" + + def test_parse_matching_5x5(self): + """Test parsing the real 5x5 matching question""" + content = """--- +tags: [ah2, provfråga, frågetyp/matching, anatomi, öra] +date: 2023-05-31 +--- +**Matcha rätt funktion med rätt lob:** +(1p för alla rätt, inga delpoäng) +- Smak +- Syn +- Somatosensorik +- Motorik +- Hörsel + +- Lobus frontalis +- Lobus Insularis +- Lobus temporalis +- Lobus parietalis +- Lobus occipitalis + +```spoiler-block: +Smak: Lobus Insularis +Syn: Lobus occipitalis +Somatosensorik: Lobus parietalis +Motorik: Lobus frontalis +Hörsel: Lobus temporalis +``` +""" + is_matching, data = parse_matching_question(content) + + self.assertTrue(is_matching) + self.assertEqual(data['question_type'], 'matching') + self.assertTrue(data['has_answer']) + self.assertEqual(len(data['left_items']), 5) + self.assertEqual(len(data['top_items']), 5) + self.assertEqual(len(data['correct_pairs']), 5) + + # Check specific items + self.assertIn('Smak', data['left_items']) + self.assertIn('Lobus frontalis', data['top_items']) + + # Check correct pairs (Smak -> Lobus Insularis) + # Smak is index 0, Lobus Insularis is index 1 + self.assertIn([0, 1], data['correct_pairs']) + + def test_parse_matching_4x4(self): + """Test parsing a 4x4 matching question""" + content = """--- +tags: [test, frågetyp/matching] +date: 2023-01-01 +--- +Match the items: + +- Item 1 +- Item 2 +- Item 3 +- Item 4 + +- Match A +- Match B +- Match C +- Match D + +```spoiler-block: +Item 1: Match B +Item 2: Match A +Item 3: Match D +Item 4: Match C +``` +""" + is_matching, data = parse_matching_question(content) + + self.assertTrue(is_matching) + self.assertEqual(len(data['left_items']), 4) + self.assertEqual(len(data['top_items']), 4) + self.assertEqual(len(data['correct_pairs']), 4) + + def test_parse_matching_todo(self): + """Test parsing matching question with TODO answer""" + content = """--- +tags: [test, frågetyp/matching] +date: 2023-01-01 +--- +Match the items: + +- Item 1 +- Item 2 + +- Match A +- Match B + +```spoiler-block: +TODO +``` +""" + is_matching, data = parse_matching_question(content) + + self.assertTrue(is_matching) + self.assertFalse(data['has_answer']) + self.assertEqual(len(data['correct_pairs']), 0) + + def test_parse_matching_no_answer(self): + """Test parsing matching question without answer block""" + content = """--- +tags: [test, frågetyp/matching] +date: 2023-01-01 +--- +Match the items: + +- Item 1 +- Item 2 + +- Match A +- Match B +""" + is_matching, data = parse_matching_question(content) + + self.assertTrue(is_matching) + self.assertFalse(data['has_answer']) + self.assertEqual(len(data['correct_pairs']), 0) diff --git a/quiz/quiz/urls.py b/quiz/quiz/urls.py index 1429eab..009819b 100644 --- a/quiz/quiz/urls.py +++ b/quiz/quiz/urls.py @@ -3,7 +3,7 @@ from django.urls import path from .views import ( index, get_next_question, submit_answer, stats, create_quiz, close_quiz, - quiz_mode, quiz_question, navigate_question, submit_difficulty + quiz_mode, quiz_question, navigate_question, submit_difficulty, tag_count_api ) urlpatterns = [ @@ -18,5 +18,6 @@ urlpatterns = [ path('close//', close_quiz, name='close_quiz'), path('stats/', stats, name='stats'), path('create/', create_quiz, name='create_quiz'), + path('api/tag-count//', tag_count_api, name='tag_count_api'), ] diff --git a/quiz/quiz/utils/importer.py b/quiz/quiz/utils/importer.py index 2b87557..a16dccd 100644 --- a/quiz/quiz/utils/importer.py +++ b/quiz/quiz/utils/importer.py @@ -70,6 +70,172 @@ class ImportStats: return "\n".join(lines) +def parse_matching_question(content: str) -> Tuple[bool, dict]: + """ + Parse matching question from markdown. + + Expected format: + - Two consecutive bullet lists (with "- " prefix) + - First list = left column items (rows) + - Second list = top row items (columns) + - Answer format: "LeftItem: TopItem" pairs + + Returns: + (is_matching, question_data) where question_data contains: + - text: question text + - left_items: list of left column items + - top_items: list of top row items + - correct_pairs: list of [left_idx, top_idx] pairs (0-indexed) + - has_answer: whether it has an answer (not TODO) + - question_type: 'matching' + """ + lines = content.split('\n') + + # Extract question text (first non-empty line after frontmatter) + question_text = None + in_frontmatter = False + frontmatter_done = False + + for line in lines: + if line.strip() == '---': + if not in_frontmatter: + in_frontmatter = True + else: + in_frontmatter = False + frontmatter_done = True + continue + + if frontmatter_done and line.strip() and not line.startswith('![['): + if not line.startswith('-') and not line.startswith('```'): + question_text = line.strip().replace('**', '') + break + + if not question_text: + return True, { + 'text': None, + 'left_items': [], + 'top_items': [], + 'correct_pairs': [], + 'has_answer': False, + 'question_type': 'matching' + } + + # Extract two consecutive bullet lists + left_items = [] + top_items = [] + in_first_list = False + in_second_list = False + in_frontmatter = False + frontmatter_done = False + found_question_text = False + + for line in lines: + # Track frontmatter + if line.strip() == '---': + if not in_frontmatter: + in_frontmatter = True + else: + in_frontmatter = False + frontmatter_done = True + continue + + if in_frontmatter or not frontmatter_done: + continue + + # Skip spoiler blocks + if line.strip().startswith('```'): + break + + # Found question text + if not found_question_text and question_text in line: + found_question_text = True + continue + + if not found_question_text: + continue + + # Look for bullet lists + if line.strip().startswith('- '): + item = line.strip()[2:].strip() + if not item: # Empty bullet + continue + + if not in_first_list and not in_second_list: + in_first_list = True + left_items.append(item) + elif in_first_list: + left_items.append(item) + elif in_second_list: + top_items.append(item) + elif line.strip() == '': + # Empty line - transition from first list to second + if in_first_list and left_items: + in_first_list = False + in_second_list = True + elif not line.strip().startswith('-') and (in_first_list or in_second_list): + # Non-bullet line after starting lists - end of lists + break + + # Parse answer from spoiler block + correct_pairs = [] + has_answer = False + in_spoiler = False + answer_lines = [] + + for line in lines: + if line.strip().startswith('```spoiler-block'): + in_spoiler = True + continue + if in_spoiler: + if line.strip() == '```': + break + stripped = line.strip() + if stripped: + answer_lines.append(stripped) + + if answer_lines: + full_answer = ' '.join(answer_lines) + + # Check for TODO + if 'TODO' in full_answer.upper(): + has_answer = False + else: + has_answer = True + # Parse "Item: Match" format + # Example: "Smak: Lobus Insularis" + for line in answer_lines: + if ':' in line: + left_part, top_part = line.split(':', 1) + left_part = left_part.strip() + top_part = top_part.strip() + + # Find indices + left_idx = None + top_idx = None + + for idx, item in enumerate(left_items): + if left_part.lower() in item.lower() or item.lower() in left_part.lower(): + left_idx = idx + break + + for idx, item in enumerate(top_items): + if top_part.lower() in item.lower() or item.lower() in top_part.lower(): + top_idx = idx + break + + if left_idx is not None and top_idx is not None: + correct_pairs.append([left_idx, top_idx]) + + return True, { + 'text': question_text, + 'left_items': left_items, + 'top_items': top_items, + 'correct_pairs': correct_pairs, + 'has_answer': has_answer, + 'question_type': 'matching' + } + + def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]: """ Parse a markdown file and extract question data. @@ -108,6 +274,8 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]: question_type = 'mcq' elif 'frågetyp/scq' in line: question_type = 'scq' + elif 'frågetyp/matching' in line: + question_type = 'matching' elif 'frågetyp/textalternativ' in line: question_type = 'textalternativ' elif 'frågetyp/textfält' in line: @@ -122,6 +290,14 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]: # Split by comma tags = [t.strip() for t in tag_content.split(',') if t.strip()] + # If it's a matching question, use the matching parser + if question_type == 'matching': + is_matching, matching_data = parse_matching_question(content) + if is_matching: + # Add tags to the data + matching_data['tags'] = tags if 'tags' in locals() else [] + return True, matching_data + if not is_question: return False, {} @@ -375,14 +551,26 @@ def import_question_file(file_path: Path, base_path: Path, stats: ImportStats, f pass # If date parsing fails, exam remains None # Import to database with mtime tracking + # Prepare defaults dict + defaults = { + 'exam': exam, + 'text': question_data['text'], + 'correct_answer': question_data.get('correct_answer', ''), + 'file_mtime': file_mtime, + 'question_type': question_data.get('question_type', 'mcq'), + } + + # Add matching_data if it's a matching question + if question_data.get('question_type') == 'matching': + defaults['matching_data'] = { + 'left_items': question_data.get('left_items', []), + 'top_items': question_data.get('top_items', []), + 'correct_pairs': question_data.get('correct_pairs', []) + } + question, created = Question.objects.update_or_create( file_path=file_path_str, - defaults={ - 'exam': exam, - 'text': question_data['text'], - 'correct_answer': question_data['correct_answer'], - 'file_mtime': file_mtime, # Track modification time - } + defaults=defaults ) if created: @@ -403,14 +591,15 @@ def import_question_file(file_path: Path, base_path: Path, stats: ImportStats, f ) question.tags.add(tag) - # Update options - question.options.all().delete() - # Deduplicate options by letter (keep first occurrence) - seen_letters = set() - for letter, text in question_data['options']: - if letter not in seen_letters: - Option.objects.create(question=question, letter=letter, text=text) - seen_letters.add(letter) + # Update options (only for MCQ/SCQ questions) + if question_data.get('question_type') not in ['matching']: + question.options.all().delete() + # Deduplicate options by letter (keep first occurrence) + seen_letters = set() + for letter, text in question_data.get('options', []): + if letter not in seen_letters: + Option.objects.create(question=question, letter=letter, text=text) + seen_letters.add(letter) return 'imported' if created else 'updated' diff --git a/quiz/quiz/views.py b/quiz/quiz/views.py index 18ccb96..7e420af 100644 --- a/quiz/quiz/views.py +++ b/quiz/quiz/views.py @@ -27,6 +27,24 @@ def handle_tag_filter(request): def create_quiz(request): if request.method == 'POST': + # Handle quick-start tag-based quiz + tag_slug = request.POST.get('tag_slug') + if tag_slug: + from .models import Tag + try: + tag = Tag.objects.get(slug=tag_slug) + course = Course.objects.first() # Get first course + session = QuizSession.objects.create( + user=request.quiz_user, + course=course, + question_types=[] + ) + session.tags.set([tag]) + return redirect('quiz_mode', session_id=session.id) + except Tag.DoesNotExist: + pass + + # Handle custom form-based quiz form = CreateQuizForm(request.POST) if form.is_valid(): course = form.cleaned_data.get('course') @@ -44,7 +62,7 @@ def create_quiz(request): if exams: session.exams.set(exams) - return redirect('index') + return redirect('quiz_mode', session_id=session.id) else: form = CreateQuizForm() @@ -67,7 +85,11 @@ def index(request): def quiz_mode(request, session_id): """Dedicated quiz mode view""" session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user, is_active=True) - return render(request, 'quiz_mode.html', {'session': session}) + total_questions = get_session_questions(session).count() + return render(request, 'quiz_mode.html', { + 'session': session, + 'total_questions': total_questions + }) def get_session_questions(session): @@ -132,13 +154,16 @@ def quiz_question(request, session_id): # Calculate navigation all_q_ids = list(all_questions.values_list('id', flat=True)) current_index = all_q_ids.index(question.id) if question.id in all_q_ids else 0 - + current_number = current_index + 1 # 1-based numbering + context = { 'question': question, 'session': session, 'show_answer': show_answer, 'has_previous': current_index > 0, 'has_next': current_index < len(all_q_ids) - 1, + 'current_number': current_number, + 'total_questions': len(all_q_ids), } if show_answer: @@ -187,12 +212,16 @@ def navigate_question(request, session_id, direction): question=question ).first() + current_number = new_index + 1 # 1-based numbering + context = { 'question': question, 'session': session, 'show_answer': result is not None, 'has_previous': new_index > 0, 'has_next': new_index < len(all_q_ids) - 1, + 'current_number': current_number, + 'total_questions': len(all_q_ids), } if result: @@ -237,7 +266,8 @@ def submit_answer(request, session_id): all_questions = get_session_questions(session) all_q_ids = list(all_questions.values_list('id', flat=True)) current_index = all_q_ids.index(question.id) if question.id in all_q_ids else 0 - + current_number = current_index + 1 # 1-based numbering + context = { 'question': question, 'session': session, @@ -245,6 +275,8 @@ def submit_answer(request, session_id): 'is_correct': is_correct, 'has_previous': current_index > 0, 'has_next': current_index < len(all_q_ids) - 1, + 'current_number': current_number, + 'total_questions': len(all_q_ids), } return render(request, 'partials/quiz_question.html', context) @@ -326,3 +358,15 @@ def stats(request): 'percentage': round((correct / total * 100) if total > 0 else 0, 1), } return render(request, 'stats.html', context) + + +def tag_count_api(request, tag_slug): + """API endpoint to get question count for a tag""" + from django.http import JsonResponse + try: + tag = Tag.objects.get(slug=tag_slug) + count = Question.objects.filter(tags=tag).count() + return JsonResponse({'count': count, 'tag': tag.name}) + except Tag.DoesNotExist: + return JsonResponse({'count': 0, 'error': 'Tag not found'}, status=404) + diff --git a/quiz/templates/index.html b/quiz/templates/index.html index 6692fbf..7337a13 100644 --- a/quiz/templates/index.html +++ b/quiz/templates/index.html @@ -1,90 +1,368 @@ {% extends "base.html" %} {% block content %} -
-
-

Välkommen

-

Här kan du hantera dina medicinska quiz.

+ + + +
+
+
{{ total_questions }}
+
Totalt frågor
-
-
{{ answered_count }} / {{ total_questions }}
-
Frågor besvarade
-
-
-
-
+
+
{{ answered_count }}
+
Besvarade
+
+
+
{{ total_questions|add:answered_count|floatformat:0|add:"-" }}{{ answered_count }}
+
Återstående
-

Aktiva Quiz

+ {% if active_sessions %} -
- {% for session in active_sessions %} -
-
-
-
- {% if session.course %}{{ session.course.name }}{% else %}Blandat Quiz{% endif %} -
-
- Startat {{ session.created_at|date:"Y-m-d H:i" }} -
+
+

🎯 Aktiva Quiz

+
+ +{% for session in active_sessions %} +
+
+
+ + {% if session.course %}{{ session.course.name }}{% else %}Blandat{% endif %} + +
+ Startat {{ session.created_at|date:"Y-m-d H:i" }}
-
- {% csrf_token %} - -
- - {% if session.tags.exists %} -
- {% for tag in session.tags.all %} - {{ - tag.name }} - {% endfor %} -
- {% endif %} - - -
- {% endfor %} -
-{% else %} -
- Inga aktiva quiz. Starta ett nytt nedan! -
-{% endif %} - -
-

Starta Nytt Quiz

-
-
+ {% csrf_token %} -
-
- - {{ form.course }} -
-
- - {{ form.tags }} - Håll ner Ctrl/Cmd för - att välja flera. -
-
-
- -
+
+ + {% if session.tags.exists %} +
+ {% for tag in session.tags.all %} + 🏷️ {{ tag.name }} + {% endfor %} +
+ {% endif %} + + + ▶️ Fortsätt Quiz +
+{% endfor %} +{% endif %} + + +
+

🚀 Snabbstart

+
+ +
+ +
+
🦴
+
Anatomi
+
Fokusera på anatomifrågor
+ ... +
+ +
+
🔬
+
Histologi
+
Fokusera på histologifrågor
+ ... +
+ +
+
🧠
+
Cerebrum
+
Hjärnans frågor
+ ... +
+ +
+
👁️
+
Öga
+
Ögats anatomi & histologi
+ ... +
+ +
+
👂
+
Öra
+
Örats anatomi & histologi
+ ... +
+ +
+
🎲
+
Alla Frågor
+
Blandat från hela kursen
+ {{ total_questions }} frågor +
+
+ + +
+

🎨 Anpassat Quiz

+

Skapa ett quiz med dina egna filter

+ +
+ {% csrf_token %} +
+
+ + {{ form.course }} +
+
+ + {{ form.tags }} +
+
+ +
+
+ + {% endblock %} \ No newline at end of file diff --git a/quiz/templates/partials/matching_question.html b/quiz/templates/partials/matching_question.html new file mode 100644 index 0000000..364f0c3 --- /dev/null +++ b/quiz/templates/partials/matching_question.html @@ -0,0 +1,207 @@ +{% csrf_token %} + + + +
{{ question.text }}
+ + + +
+ + + + + {% for top_item in question.matching_data.top_items %} + + {% endfor %} + + + + {% for left_item in question.matching_data.left_items %} + + + {% for top_item in question.matching_data.top_items %} + + {% endfor %} + + {% endfor %} + +
+
{{ forloop.counter }}. {{ top_item|truncatewords:4 }}
+
+ {{ forloop.counter }}. {{ left_item }} + + {% if forloop.parentloop.counter0 != forloop.counter0 %} + + {% else %} + + + {% endif %} +
+ +
+

Kolumnalternativ:

+ {% for top_item in question.matching_data.top_items %} +
+ {{ forloop.counter }}. {{ top_item }} +
+ {% endfor %} +
+
+ + \ No newline at end of file diff --git a/quiz/templates/partials/quiz_question.html b/quiz/templates/partials/quiz_question.html index 527df1d..5079816 100644 --- a/quiz/templates/partials/quiz_question.html +++ b/quiz/templates/partials/quiz_question.html @@ -1,27 +1,48 @@ +{% if question.question_type == 'matching' %} +{% include 'partials/matching_question.html' %} +{% else %} +{# Regular MCQ/SCQ template #} {% csrf_token %} +
{{ question.text }}
{% for option in question.options.all %} -
- \ No newline at end of file + +{% endif %} \ No newline at end of file diff --git a/quiz/templates/quiz_mode.html b/quiz/templates/quiz_mode.html index 8bbd960..1e863fe 100644 --- a/quiz/templates/quiz_mode.html +++ b/quiz/templates/quiz_mode.html @@ -77,8 +77,9 @@ .nav-buttons { display: flex; - justify-content: space-between; - margin-top: 2rem; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 1.5rem; } .nav-btn { @@ -100,6 +101,133 @@ background: #cbd5e1; cursor: not-allowed; } + + .question-navigator { + background: white; + border-radius: 1.5rem 1.5rem 0 0; + padding: 1.5rem; + box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.3); + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 1000; + } + + .navigator-content { + display: flex; + align-items: center; + gap: 1rem; + max-width: 1200px; + margin: 0 auto; + } + + .navigator-label { + font-weight: 600; + color: var(--text-muted); + font-size: 0.875rem; + white-space: nowrap; + } + + .question-pills { + display: flex; + gap: 0.5rem; + flex: 1; + overflow-x: auto; + padding: 0.25rem 0.5rem; + scroll-behavior: smooth; + /* Hide scrollbar but keep functionality */ + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE/Edge */ + position: relative; + max-width: calc(100vw - 400px); /* Leave space for label and buttons */ + } + + .question-pills::-webkit-scrollbar { + display: none; /* Chrome/Safari */ + } + + /* Fade effect at edges */ + .question-pills::before, + .question-pills::after { + content: '⋯'; + position: absolute; + top: 50%; + transform: translateY(-50%); + font-size: 1.5rem; + font-weight: bold; + color: var(--text-muted); + pointer-events: none; + z-index: 1; + opacity: 0; + transition: opacity 0.3s; + } + + .question-pills::before { + left: 0; + background: linear-gradient(to right, white 20%, transparent); + padding-right: 1rem; + } + + .question-pills::after { + right: 0; + background: linear-gradient(to left, white 20%, transparent); + padding-left: 1rem; + } + + .question-pills.show-left-ellipsis::before { + opacity: 1; + } + + .question-pills.show-right-ellipsis::after { + opacity: 1; + } + + .question-pill { + min-width: 2.5rem; + height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.5rem; + border: 2px solid var(--border); + background: white; + color: var(--text-main); + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + font-size: 0.875rem; + flex-shrink: 0; /* Prevent pills from shrinking */ + white-space: nowrap; + } + + .question-pill:hover { + border-color: var(--primary); + transform: translateY(-2px); + } + + .question-pill.active { + background: var(--primary); + color: white; + border-color: var(--primary); + } + + .question-pill.answered { + background: #f0f4ff; + border-color: var(--primary); + color: var(--primary); + } + + .question-pill.answered.active { + background: var(--primary); + color: white; + } + + .navigator-buttons { + display: flex; + gap: 0.5rem; + white-space: nowrap; + }
@@ -111,16 +239,37 @@
+ +
+ +
{% endblock %} \ No newline at end of file