diff --git a/examples/tokens_siswa.csv b/examples/tokens_siswa.csv index 9aaa711..4421455 100644 --- a/examples/tokens_siswa.csv +++ b/examples/tokens_siswa.csv @@ -1,3 +1,3 @@ -token;nama_siswa;hello_world;variabel;rangkaian_dasar;led_blink_arduino -121312;Anggoro Dwi;completed;completed;completed;completed -dummy_token_12345;Example Student;completed;not_started;not_started;not_started +token;nama_siswa;button_input_arduino;flowchart_test;hello_serial_arduino;hello_world;latex_test;led_blink_arduino;rangkaian_dasar;traffic_light_arduino;variabel +121312;Anggoro Dwi;not_started;completed;not_started;completed;not_started;completed;completed;not_started;completed +dummy_token_12345;Example Student;not_started;not_started;not_started;completed;not_started;not_started;not_started;not_started;not_started diff --git a/frontend/src/lib/components/LessonCard.svelte b/frontend/src/lib/components/LessonCard.svelte index 38b0cec..e5f26fa 100644 --- a/frontend/src/lib/components/LessonCard.svelte +++ b/frontend/src/lib/components/LessonCard.svelte @@ -4,11 +4,13 @@ let { lesson }: { lesson: Lesson } = $props(); - +

{#if lesson.completed} + {:else if lesson.locked} + 🔒 {/if} {lesson.title}

@@ -28,6 +30,12 @@ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); text-decoration: none; } + .lesson-card.is-locked { + background-color: var(--color-bg-muted); + } + .lesson-card.is-locked .card-title { + color: var(--color-text-muted); + } .card-body { padding: 1.25rem; } @@ -56,4 +64,17 @@ font-size: 0.75rem; flex-shrink: 0; } + .badge-locked { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + background: var(--color-border); + color: var(--color-text-muted); + border-radius: 50%; + font-size: 0.7rem; + flex-shrink: 0; + line-height: 1; + } diff --git a/frontend/src/lib/components/WorkspaceHeader.svelte b/frontend/src/lib/components/WorkspaceHeader.svelte index 4d5338e..d8d2006 100644 --- a/frontend/src/lib/components/WorkspaceHeader.svelte +++ b/frontend/src/lib/components/WorkspaceHeader.svelte @@ -15,6 +15,7 @@ onResizeStart: (e: MouseEvent) => void; onFloatToggle: () => void; onMinimize: () => void; + locked?: boolean; } let { @@ -31,6 +32,7 @@ onResizeStart, onFloatToggle, onMinimize, + locked = false, }: Props = $props(); let touchStartY = 0; @@ -130,7 +132,9 @@ {@render chromeTabs()}
- + {#if !locked} + + {/if}
{/if} diff --git a/frontend/src/lib/types/lesson.ts b/frontend/src/lib/types/lesson.ts index 822dc7a..73af3ab 100644 --- a/frontend/src/lib/types/lesson.ts +++ b/frontend/src/lib/types/lesson.ts @@ -3,6 +3,8 @@ export interface Lesson { title: string; description: string; completed: boolean; + prerequisites?: string[]; + locked?: boolean; } export interface LessonContent { @@ -29,6 +31,9 @@ export interface LessonContent { key_text: string; lesson_title: string; lesson_completed: boolean; + locked?: boolean; + error?: string; + missing_prerequisites?: string[]; prev_lesson: Lesson | null; next_lesson: Lesson | null; ordered_lessons: Lesson[]; diff --git a/frontend/src/routes/lesson/[slug]/+page.svelte b/frontend/src/routes/lesson/[slug]/+page.svelte index 9af76d8..4a017ce 100644 --- a/frontend/src/routes/lesson/[slug]/+page.svelte +++ b/frontend/src/routes/lesson/[slug]/+page.svelte @@ -113,6 +113,20 @@ } }); + // Force docked mode if lesson is locked + $effect(() => { + if (data?.locked && float.floating) { + float.floating = false; + float.minimized = false; + } + }); + + // Helper to find lesson title by slug + function getLessonTitle(slug: string) { + const lesson = data?.ordered_lessons?.find(l => l.filename.replace('.md', '') === slug); + return lesson?.title || slug.replace(/_/g, ' ').toUpperCase(); + } + // Derived: is this a hybrid lesson (has both code and circuit)? let isHybrid = $derived( (data?.active_tabs?.includes('c') || data?.active_tabs?.includes('python')) && @@ -634,6 +648,24 @@ oncopy={(e) => e.preventDefault()} oncut={(e) => e.preventDefault()} oncontextmenu={(e) => e.preventDefault()}> + + {#if data.locked} +
+ 🔒 +
+ Pelajaran ini terkunci. Kamu dapat membaca materinya, namun workspace (editor) dinonaktifkan hingga prasyarat selesai. + {#if data.missing_prerequisites && data.missing_prerequisites.length > 0} +
+ Belum selesai: + {#each data.missing_prerequisites as p, i} + {getLessonTitle(p)}{i < data.missing_prerequisites.length - 1 ? ', ' : ''} + {/each} +
+ {/if} +
+
+ {/if} +
{@html data.lesson_content}
@@ -656,6 +688,7 @@ class:mobile-hidden={isMobile && mobileMode === 'hidden'} class:mobile-half={isMobile && mobileMode === 'half'} class:mobile-full={isMobile && mobileMode === 'full'} + class:editor-locked={data.locked} style={float.style}> -
+
+ {#if data.locked} +
+
+
🔒
+

Workspace Terkunci

+

Selesaikan materi sebelumnya untuk mulai mengerjakan latihan ini.

+
+
+ {/if} @@ -784,6 +827,91 @@
- {/if} + + diff --git a/routes/lessons.py b/routes/lessons.py index 8499f28..6924582 100644 --- a/routes/lessons.py +++ b/routes/lessons.py @@ -29,6 +29,18 @@ def api_lessons(): progress = get_student_progress(token) lessons = get_ordered_lessons_with_learning_objectives(progress) + + # Calculate locked status + for lesson in lessons: + prereqs = lesson.get('prerequisites', []) + is_locked = False + if prereqs: + for p_slug in prereqs: + if not progress or progress.get(p_slug) != 'completed': + is_locked = True + break + lesson['locked'] = is_locked + home_content = render_home_content() return jsonify({ @@ -103,11 +115,23 @@ def api_lesson(filename): all_lessons = get_ordered_lessons_with_learning_objectives(progress) current_idx = -1 + current_lesson_meta = None for i, les in enumerate(all_lessons): if les['filename'] == full_filename: current_idx = i + current_lesson_meta = les break + # Prerequisite check + is_locked = False + missing_prereqs = [] + if current_lesson_meta: + prereqs = current_lesson_meta.get('prerequisites', []) + for p_slug in prereqs: + if not progress or progress.get(p_slug) != 'completed': + is_locked = True + missing_prereqs.append(p_slug) + prev_lesson = all_lessons[current_idx - 1] if current_idx > 0 else None next_lesson = all_lessons[current_idx + 1] if 0 <= current_idx < len(all_lessons) - 1 else None @@ -118,6 +142,30 @@ def api_lesson(filename): programming_language = 'c' language_display_name = compiler_factory.get_language_display_name(programming_language) + if is_locked: + # Strip sensitive data to prevent inspection bypass + exercise_html = "" + expected_output = "" + expected_output_python = "" + expected_circuit_output = "" + initial_code = "// Konten terkunci. Selesaikan prasyarat untuk melihat." + initial_circuit = "" + initial_code_c = "" + initial_python = "" + initial_flowchart = None + initial_quiz = "" + initial_code_arduino = "" + velxio_circuit = "" + expected_serial_output = "" + expected_wiring = "" + expected_flowchart = "" + solution_code = "" + solution_circuit = "" + solution_python = "" + key_text = "" + key_text_circuit = "" + # Keep lesson_html, lesson_info, etc. for reading + return jsonify({ 'lesson_content': lesson_html, 'exercise_content': exercise_html, @@ -145,6 +193,8 @@ def api_lesson(filename): 'active_tabs': active_tabs, 'lesson_title': full_filename.replace('.md', '').replace('_', ' ').title(), 'lesson_completed': lesson_completed, + 'locked': is_locked, + 'missing_prerequisites': missing_prereqs, 'prev_lesson': prev_lesson, 'next_lesson': next_lesson, 'ordered_lessons': all_lessons, diff --git a/services/lesson_service.py b/services/lesson_service.py index dd860d2..df61142 100644 --- a/services/lesson_service.py +++ b/services/lesson_service.py @@ -118,10 +118,12 @@ def get_lessons_with_learning_objectives(): lesson_info_start = content.find('---LESSON_INFO---') lesson_info_end = content.find('---END_LESSON_INFO---') + prerequisite_titles = [] if lesson_info_start != -1 and lesson_info_end != -1: lesson_info_section = content[lesson_info_start + len('---LESSON_INFO---'):lesson_info_end] + # Extract Learning Objectives objectives_start = lesson_info_section.find('**Learning Objectives:**') if objectives_start != -1: objectives_section = lesson_info_section[objectives_start:] @@ -132,6 +134,23 @@ def get_lessons_with_learning_objectives(): lines_after = lesson_info_section[objectives_start:].split('\n')[1:4] description = ' '.join(line.strip() for line in lines_after if line.strip()) + # Extract Prerequisites + prereq_start = lesson_info_section.find('**Prerequisites:**') + if prereq_start != -1: + prereq_section = lesson_info_section[prereq_start + len('**Prerequisites:**'):] + # Look for bullet points + prerequisite_titles = re.findall(r'- ([^\n]+)', prereq_section) + if not prerequisite_titles: + # Fallback to lines until next bold or end + next_bold = prereq_section.find('**') + if next_bold != -1: + prereq_section = prereq_section[:next_bold] + prerequisite_titles = [l.strip() for l in prereq_section.split('\n') if l.strip()] + + # Filter out "None" or "Tidak ada" + prerequisite_titles = [t.strip() for t in prerequisite_titles + if t.strip().lower() not in ('tidak ada', 'none', '-', '')] + content_after_info = content[lesson_info_end + len('---END_LESSON_INFO---'):].strip() for line in content_after_info.split('\n'): if line.startswith('# '): @@ -150,6 +169,7 @@ def get_lessons_with_learning_objectives(): 'title': title, 'description': description, 'path': file_path, + 'prerequisite_titles': prerequisite_titles, }) return lessons @@ -162,12 +182,22 @@ def get_ordered_lessons_with_learning_objectives(progress=None): all_lessons = get_lessons_with_learning_objectives() - def _add_completion(lesson, progress): + # Build title -> slug mapping for prerequisite resolution + title_to_slug = {lesson['title']: lesson['filename'].replace('.md', '') for lesson in all_lessons} + # Also map link text from home.md + for link_text, filename in lesson_links: + title_to_slug[link_text] = filename.replace('.md', '') + + def _add_completion_and_prereqs(lesson, progress): + slug = lesson['filename'].replace('.md', '') if progress: - lesson_key = lesson['filename'].replace('.md', '') - lesson['completed'] = progress.get(lesson_key) == 'completed' + lesson['completed'] = progress.get(slug) == 'completed' else: lesson['completed'] = False + + # Resolve prerequisite titles to slugs + titles = lesson.get('prerequisite_titles', []) + lesson['prerequisites'] = [title_to_slug[t] for t in titles if t in title_to_slug] return lesson if lesson_links: @@ -177,7 +207,7 @@ def get_ordered_lessons_with_learning_objectives(progress=None): if lesson['filename'] == filename: copy = lesson.copy() copy['title'] = link_text - _add_completion(copy, progress) + _add_completion_and_prereqs(copy, progress) ordered.append(copy) break @@ -185,7 +215,7 @@ def get_ordered_lessons_with_learning_objectives(progress=None): for lesson in all_lessons: if lesson['filename'] not in seen: copy = lesson.copy() - _add_completion(copy, progress) + _add_completion_and_prereqs(copy, progress) ordered.append(copy) return ordered @@ -193,7 +223,7 @@ def get_ordered_lessons_with_learning_objectives(progress=None): ordered_fallback = [] for lesson in all_lessons: copy = lesson.copy() - _add_completion(copy, progress) + _add_completion_and_prereqs(copy, progress) ordered_fallback.append(copy) return ordered_fallback