Compare commits
2 Commits
d3acfcf825
...
0528df2d37
| Author | SHA1 | Date |
|---|---|---|
|
|
0528df2d37 | |
|
|
9b745f52f4 |
|
|
@ -16,8 +16,9 @@
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let view: any;
|
let view: any;
|
||||||
let ready = $state(false);
|
let ready = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
let lastThemeDark: boolean | undefined;
|
let lastThemeDark: boolean | undefined;
|
||||||
let lastStorageKey = storageKey;
|
let lastStorageKey = $state<string | undefined>(undefined);
|
||||||
|
|
||||||
// Store module references after dynamic import
|
// Store module references after dynamic import
|
||||||
let CM: any;
|
let CM: any;
|
||||||
|
|
@ -26,9 +27,11 @@
|
||||||
|
|
||||||
function saveToStorage(value: string) {
|
function saveToStorage(value: string) {
|
||||||
if (!storageKey) return;
|
if (!storageKey) return;
|
||||||
|
saving = true;
|
||||||
clearTimeout(saveTimeout);
|
clearTimeout(saveTimeout);
|
||||||
saveTimeout = setTimeout(() => {
|
saveTimeout = setTimeout(() => {
|
||||||
sessionStorage.setItem(storageKey, value);
|
sessionStorage.setItem(storageKey, value);
|
||||||
|
saving = false;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -286,22 +289,66 @@
|
||||||
return view?.state.doc.toString() ?? code;
|
return view?.state.doc.toString() ?? code;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="editor-wrapper" class:no-paste={noPaste} bind:this={container}>
|
<div class="editor-wrapper" class:no-paste={noPaste} bind:this={container}>
|
||||||
{#if !ready}
|
{#if !ready}
|
||||||
<div class="editor-loading">Memuat editor...</div>
|
<div class="editor-loading">Memuat editor...</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if storageKey}
|
||||||
|
<div class="storage-indicator" title={saving ? "Menyimpan draf..." : "Draf tersimpan di browser"}>
|
||||||
|
<span class="indicator-icon" class:saving>
|
||||||
|
{saving ? '●' : '☁'}
|
||||||
|
</span>
|
||||||
|
<span class="indicator-text">Auto-save</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.editor-wrapper {
|
.editor-wrapper {
|
||||||
|
position: relative;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
|
.storage-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.indicator-icon {
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.indicator-icon.saving {
|
||||||
|
color: var(--color-primary);
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
.indicator-text {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
.editor-wrapper :global(.cm-editor) {
|
.editor-wrapper :global(.cm-editor) {
|
||||||
|
...
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,11 @@ export const auth = {
|
||||||
authIsTeacher.set(res.is_teacher ?? false);
|
authIsTeacher.set(res.is_teacher ?? false);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
sessionStorage.clear();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
sessionStorage.clear();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -51,6 +53,7 @@ export const auth = {
|
||||||
authLoggedIn.set(true);
|
authLoggedIn.set(true);
|
||||||
authIsTeacher.set(res.is_teacher ?? false);
|
authIsTeacher.set(res.is_teacher ?? false);
|
||||||
localStorage.setItem(STORAGE_KEY, inputToken);
|
localStorage.setItem(STORAGE_KEY, inputToken);
|
||||||
|
sessionStorage.clear();
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|
@ -62,5 +65,6 @@ export const auth = {
|
||||||
authLoggedIn.set(false);
|
authLoggedIn.set(false);
|
||||||
authIsTeacher.set(false);
|
authIsTeacher.set(false);
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
sessionStorage.clear();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
import OutputPanel from '$components/OutputPanel.svelte';
|
import OutputPanel from '$components/OutputPanel.svelte';
|
||||||
import CelebrationOverlay from '$components/CelebrationOverlay.svelte';
|
import CelebrationOverlay from '$components/CelebrationOverlay.svelte';
|
||||||
import { compileCode, trackProgress } from '$services/api';
|
import { compileCode, trackProgress } from '$services/api';
|
||||||
import { auth } from '$stores/auth';
|
import { auth, authLoggedIn } from '$stores/auth';
|
||||||
import { lessonContext } from '$stores/lessonContext';
|
import { lessonContext } from '$stores/lessonContext';
|
||||||
import { noSelect } from '$actions/noSelect';
|
import { noSelect } from '$actions/noSelect';
|
||||||
import { createFloatingPanel } from '$actions/floatingPanel.svelte';
|
import { createFloatingPanel } from '$actions/floatingPanel.svelte';
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
let lesson = $derived(pageData.lesson);
|
let lesson = $derived(pageData.lesson);
|
||||||
|
|
||||||
let data = $state<LessonContent | null>(null);
|
let data = $state<LessonContent | null>(null);
|
||||||
|
let lessonCompleted = $state(false);
|
||||||
let currentCode = $state('');
|
let currentCode = $state('');
|
||||||
let compileOutput = $state('');
|
let compileOutput = $state('');
|
||||||
let compileError = $state('');
|
let compileError = $state('');
|
||||||
|
|
@ -90,6 +91,7 @@
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (lesson) {
|
if (lesson) {
|
||||||
data = lesson;
|
data = lesson;
|
||||||
|
lessonCompleted = lesson.lesson_completed;
|
||||||
currentCode = lesson.initial_code ?? '';
|
currentCode = lesson.initial_code ?? '';
|
||||||
compileOutput = '';
|
compileOutput = '';
|
||||||
compileError = '';
|
compileError = '';
|
||||||
|
|
@ -156,7 +158,7 @@
|
||||||
if (auth.isLoggedIn) {
|
if (auth.isLoggedIn) {
|
||||||
const lessonName = slug.replace('.md', '');
|
const lessonName = slug.replace('.md', '');
|
||||||
await trackProgress(auth.token, lessonName);
|
await trackProgress(auth.token, lessonName);
|
||||||
data.lesson_completed = true;
|
lessonCompleted = true;
|
||||||
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
|
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
|
||||||
}
|
}
|
||||||
// Auto-show solution after celebration
|
// Auto-show solution after celebration
|
||||||
|
|
@ -342,7 +344,7 @@
|
||||||
{compiling ? 'Compiling...' : '\u25B6 Run'}
|
{compiling ? 'Compiling...' : '\u25B6 Run'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-secondary" onclick={handleReset}>Reset</button>
|
<button type="button" class="btn btn-secondary" onclick={handleReset}>Reset</button>
|
||||||
{#if data.solution_code && auth.isLoggedIn && data.lesson_completed}
|
{#if data.solution_code && $authLoggedIn && lessonCompleted}
|
||||||
<button type="button" class="btn btn-secondary" onclick={handleShowSolution}>
|
<button type="button" class="btn btn-secondary" onclick={handleShowSolution}>
|
||||||
{showSolution ? 'Sembunyikan Solusi' : 'Lihat Solusi'}
|
{showSolution ? 'Sembunyikan Solusi' : 'Lihat Solusi'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -356,7 +358,7 @@
|
||||||
code={currentCode}
|
code={currentCode}
|
||||||
language={data.language}
|
language={data.language}
|
||||||
noPaste={true}
|
noPaste={true}
|
||||||
storageKey={showSolution ? undefined : `elemes_draft_${slug}`}
|
storageKey={($authLoggedIn && !showSolution) ? `elemes_draft_${slug}` : undefined}
|
||||||
onchange={(val) => { if (!showSolution) currentCode = val; }}
|
onchange={(val) => { if (!showSolution) currentCode = val; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ bind = "0.0.0.0:5000"
|
||||||
backlog = 2048
|
backlog = 2048
|
||||||
|
|
||||||
# Worker processes
|
# Worker processes
|
||||||
workers = 4
|
workers = 2
|
||||||
worker_class = "sync"
|
worker_class = "gthread"
|
||||||
|
threads = 4
|
||||||
worker_connections = 1000
|
worker_connections = 1000
|
||||||
timeout = 120
|
timeout = 120
|
||||||
keepalive = 5
|
keepalive = 5
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ Lesson loading, ordering, and markdown rendering.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
import markdown as md
|
import markdown as md
|
||||||
|
|
||||||
from config import CONTENT_DIR
|
from config import CONTENT_DIR
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
def _read_home_md():
|
def _read_home_md():
|
||||||
"""Read home.md and return its content, or empty string if missing."""
|
"""Read home.md and return its content, or empty string if missing."""
|
||||||
path = os.path.join(CONTENT_DIR, "home.md")
|
path = os.path.join(CONTENT_DIR, "home.md")
|
||||||
|
|
@ -32,6 +34,7 @@ def _parse_lesson_links(home_content):
|
||||||
# Lesson listing
|
# Lesson listing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32)
|
||||||
def get_lessons():
|
def get_lessons():
|
||||||
"""Get lessons from the Available_Lessons section in home.md."""
|
"""Get lessons from the Available_Lessons section in home.md."""
|
||||||
lessons = []
|
lessons = []
|
||||||
|
|
@ -71,6 +74,7 @@ def get_lessons():
|
||||||
return lessons
|
return lessons
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32)
|
||||||
def get_lesson_names():
|
def get_lesson_names():
|
||||||
"""Get lesson names (without .md extension) from Available_Lessons."""
|
"""Get lesson names (without .md extension) from Available_Lessons."""
|
||||||
home_content = _read_home_md()
|
home_content = _read_home_md()
|
||||||
|
|
@ -85,6 +89,7 @@ def get_lesson_names():
|
||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32)
|
||||||
def get_lessons_with_learning_objectives():
|
def get_lessons_with_learning_objectives():
|
||||||
"""Get lessons with learning objectives extracted from LESSON_INFO sections."""
|
"""Get lessons with learning objectives extracted from LESSON_INFO sections."""
|
||||||
lessons = []
|
lessons = []
|
||||||
|
|
@ -171,14 +176,18 @@ def get_ordered_lessons_with_learning_objectives(progress=None):
|
||||||
seen = {l['filename'] for l in ordered}
|
seen = {l['filename'] for l in ordered}
|
||||||
for lesson in all_lessons:
|
for lesson in all_lessons:
|
||||||
if lesson['filename'] not in seen:
|
if lesson['filename'] not in seen:
|
||||||
_add_completion(lesson, progress)
|
copy = lesson.copy()
|
||||||
ordered.append(lesson)
|
_add_completion(copy, progress)
|
||||||
|
ordered.append(copy)
|
||||||
|
|
||||||
return ordered
|
return ordered
|
||||||
|
|
||||||
|
ordered_fallback = []
|
||||||
for lesson in all_lessons:
|
for lesson in all_lessons:
|
||||||
_add_completion(lesson, progress)
|
copy = lesson.copy()
|
||||||
return all_lessons
|
_add_completion(copy, progress)
|
||||||
|
ordered_fallback.append(copy)
|
||||||
|
return ordered_fallback
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -203,6 +212,7 @@ def _extract_section(content, start_marker, end_marker):
|
||||||
return extracted, remaining
|
return extracted, remaining
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32)
|
||||||
def render_markdown_content(file_path):
|
def render_markdown_content(file_path):
|
||||||
"""Parse a lesson markdown file and return structured HTML parts.
|
"""Parse a lesson markdown file and return structured HTML parts.
|
||||||
|
|
||||||
|
|
@ -250,6 +260,7 @@ def render_markdown_content(file_path):
|
||||||
return lesson_html, exercise_html, expected_output, lesson_info_html, initial_code, solution_code, key_text
|
return lesson_html, exercise_html, expected_output, lesson_info_html, initial_code, solution_code, key_text
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
def render_home_content():
|
def render_home_content():
|
||||||
"""Render the home.md intro section (before Available_Lessons) as HTML."""
|
"""Render the home.md intro section (before Available_Lessons) as HTML."""
|
||||||
home_content = _read_home_md()
|
home_content = _read_home_md()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue