feat: implement secured lesson prerequisite system with interactive links and auto-docking UI
This commit is contained in:
parent
9910a92364
commit
9ed207d2fa
|
|
@ -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
|
||||
|
|
|
|||
|
|
|
@ -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">✓</span>
|
||||
{:else if lesson.locked}
|
||||
<span class="badge-locked" title="Terkunci">🔒</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>
|
||||
|
|
|
|||
|
|
@ -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">⊞</button>
|
||||
{#if !locked}
|
||||
<button type="button" class="btn-float-toggle" onclick={onFloatToggle} title="Float editor">⊞</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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">🔒</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">🔒</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue