feat: implement secured lesson prerequisite system with interactive links and auto-docking UI

This commit is contained in:
a2nr 2026-05-07 10:54:42 +07:00
parent 9910a92364
commit 9ed207d2fa
7 changed files with 251 additions and 13 deletions

View File

@ -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

1 token nama_siswa button_input_arduino flowchart_test hello_serial_arduino hello_world latex_test led_blink_arduino rangkaian_dasar traffic_light_arduino variabel
2 121312 Anggoro Dwi not_started completed not_started completed not_started completed completed not_started completed
3 dummy_token_12345 Example Student not_started not_started not_started completed not_started not_started not_started not_started not_started

View File

@ -4,11 +4,13 @@
let { lesson }: { lesson: Lesson } = $props();
</script>
<a href="/lesson/{lesson.filename}" class="card lesson-card">
<a href="/lesson/{lesson.filename}" class="card lesson-card" class:is-locked={lesson.locked}>
<div class="card-body">
<h3 class="card-title">
{#if lesson.completed}
<span class="badge-done" title="Selesai">&#10003;</span>
{:else if lesson.locked}
<span class="badge-locked" title="Terkunci">&#128274;</span>
{/if}
{lesson.title}
</h3>
@ -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;
}
</style>

View File

@ -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()}
</div>
<div class="panel-actions">
<button type="button" class="btn-float-toggle" onclick={onFloatToggle} title="Float editor">&#x229E;</button>
{#if !locked}
<button type="button" class="btn-float-toggle" onclick={onFloatToggle} title="Float editor">&#x229E;</button>
{/if}
</div>
</div>
{/if}

View File

@ -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[];

View File

@ -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}
<div class="locked-banner">
<span class="locked-banner-icon">&#128274;</span>
<div>
<strong>Pelajaran ini terkunci.</strong> Kamu dapat membaca materinya, namun workspace (editor) dinonaktifkan hingga prasyarat selesai.
{#if data.missing_prerequisites && data.missing_prerequisites.length > 0}
<div class="missing-list">
Belum selesai:
{#each data.missing_prerequisites as p, i}
<a href="/lesson/{p}" class="prereq-link">{getLessonTitle(p)}</a>{i < data.missing_prerequisites.length - 1 ? ', ' : ''}
{/each}
</div>
{/if}
</div>
</div>
{/if}
<div class="prose">{@html data.lesson_content}</div>
<LessonList lessons={data.ordered_lessons ?? []} currentSlug={slug} />
@ -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}>
<WorkspaceHeader
@ -672,10 +705,20 @@
onResizeStart={float.onResizeStart}
onFloatToggle={float.toggle}
onMinimize={float.minimize}
locked={data.locked}
/>
<!-- Editor body -->
<div class="editor-body" bind:this={tabsEl} use:renderMath>
<div class="editor-body" bind:this={tabsEl} use:renderMath style="position: relative;">
{#if data.locked}
<div class="workspace-lock-overlay">
<div class="lock-overlay-content">
<div class="lock-overlay-icon">&#128274;</div>
<h3>Workspace Terkunci</h3>
<p>Selesaikan materi sebelumnya untuk mulai mengerjakan latihan ini.</p>
</div>
</div>
{/if}
<!-- Info tab panel -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
@ -784,6 +827,91 @@
<CelebrationOverlay visible={showCelebration} />
</div>
</div>
{/if}
<style>
.locked-banner {
background: #fff9db;
border: 1px solid #fab005;
color: #862e00;
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
display: flex;
align-items: center;
gap: 1rem;
font-size: 0.95rem;
line-height: 1.4;
}
.locked-banner-icon {
font-size: 1.25rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: rgba(134, 46, 0, 0.1);
border-radius: 50%;
}
.missing-list {
font-size: 0.8rem;
margin-top: 0.25rem;
font-weight: 600;
}
.prereq-link {
color: #862e00;
text-decoration: underline;
font-weight: 700;
}
.prereq-link:hover {
color: #5c1e00;
}
.workspace-lock-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
text-align: center;
border-radius: inherit;
}
.lock-overlay-content {
max-width: 280px;
}
.lock-overlay-content h3 {
margin: 0.75rem 0 0.5rem;
font-size: 1.1rem;
color: var(--color-text);
}
.lock-overlay-content p {
color: var(--color-text-muted);
font-size: 0.85rem;
line-height: 1.4;
}
.lock-overlay-icon {
font-size: 2.5rem;
line-height: 1;
margin-bottom: 0.5rem;
display: inline-block;
}
@media (max-width: 600px) {
.lock-overlay-icon {
font-size: 2rem;
}
.lock-overlay-content h3 {
font-size: 1rem;
}
}
.editor-locked {
overflow: hidden;
}
</style>

View File

@ -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,

View File

@ -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