diff --git a/README.md b/README.md index c27a36c..cd53ea2 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,7 @@ Untuk materi yang menggunakan simulator Arduino (Velxio): | `---EXPECTED_SERIAL_OUTPUT---` | Output serial yang diharapkan (subsequence match) | | `---EXPECTED_WIRING---` | Wiring yang harus dibuat siswa (JSON, lenient) | | `---KEY_TEXT---` | Kata kunci yang harus ada di kode siswa | +| `---EVALUATION_CONFIG---` | Konfigurasi tambahan evaluasi Arduino (JSON: e.g. `timeout_ms` dalam milidetik) | Contoh materi Arduino: @@ -246,6 +247,12 @@ LED OFF pinMode digitalWrite ---END_KEY_TEXT--- + +---EVALUATION_CONFIG--- +{ + "timeout_ms": 8000 +} +---END_EVALUATION_CONFIG--- ``` ##### Referensi Nama Pin Komponen Velxio diff --git a/documentation.md b/documentation.md index 573c094..853c27c 100644 --- a/documentation.md +++ b/documentation.md @@ -312,6 +312,7 @@ Elemes mendukung beberapa mode lesson melalui **marker** di file markdown. Mode | `---KEY_TEXT_CIRCUIT---` / `---END_KEY_TEXT_CIRCUIT---` | Keyword wajib di circuit (hybrid) | | `---SOLUTION_CODE---` / `---END_SOLUTION_CODE---` | Solusi kode (ditampilkan setelah selesai) | | `---SOLUTION_CIRCUIT---` / `---END_SOLUTION_CIRCUIT---` | Solusi circuit | +| `---EVALUATION_CONFIG---` / `---END_EVALUATION_CONFIG---` | Konfigurasi tambahan evaluasi Arduino (JSON: e.g. `timeout_ms`) | ### Fitur Tombol "Coba" (Code Try-out) diff --git a/frontend/src/app.html b/frontend/src/app.html index 0c79107..3dcf69e 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -14,5 +14,14 @@
%sveltekit.body%
+ diff --git a/frontend/src/lib/components/CircuitEditor.svelte b/frontend/src/lib/components/CircuitEditor.svelte index be9faf9..75b74c2 100644 --- a/frontend/src/lib/components/CircuitEditor.svelte +++ b/frontend/src/lib/components/CircuitEditor.svelte @@ -15,18 +15,21 @@ let ready = $state(false); let simApi = $state(null); let saving = $state(false); - let saveTimeout: ReturnType; - let autoSaveInterval: ReturnType; + let saveTimeout = $state | null>(null); + let isDestroyed = false; let lastLoadedCircuit = $state(''); let lastStorageKey = $state(undefined); function saveToStorage(text: string) { - if (!storageKey) return; + if (!storageKey || isDestroyed) return; saving = true; - clearTimeout(saveTimeout); + if (saveTimeout) clearTimeout(saveTimeout); saveTimeout = setTimeout(() => { - localStorage.setItem(storageKey, text); + if (!isDestroyed && storageKey) { + localStorage.setItem(storageKey, text); + } saving = false; + saveTimeout = null; }, 1000); } @@ -137,8 +140,11 @@ // Cleanup timers on destroy $effect(() => { return () => { - clearInterval(autoSaveInterval); - clearTimeout(saveTimeout); + isDestroyed = true; + if (saveTimeout) { + clearTimeout(saveTimeout); + saveTimeout = null; + } }; }); diff --git a/frontend/src/lib/services/velxio-manager.ts b/frontend/src/lib/services/velxio-manager.ts index d4151e3..79d145e 100644 --- a/frontend/src/lib/services/velxio-manager.ts +++ b/frontend/src/lib/services/velxio-manager.ts @@ -44,9 +44,12 @@ export function initVelxioBridge( arduinoCircuitKey: string, arduinoCodeKey: string, onReady: (bridge: VelxioBridge) => void, - onSubmit: () => void -) { + onSubmit: () => void, + onCountdown?: (msRemaining: number) => void +): () => void { let settled = false; + let countdownInterval: ReturnType | null = null; + const onMessage = (e: MessageEvent) => { const type = e.data?.type; if (!type) return; @@ -83,8 +86,30 @@ export function initVelxioBridge( } if (type === 'velxio:compile_result' && e.data.success) { - const timeout = data?.evaluation_config?.timeout_ms ?? 5000; - setTimeout(() => onSubmit(), timeout); + const timeout = data?.evaluation_config?.timeout_ms ?? 8000; + if (countdownInterval) clearInterval(countdownInterval); + let remaining = timeout; + if (onCountdown) { + onCountdown(remaining); + countdownInterval = setInterval(() => { + remaining -= 1000; + if (remaining <= 0) { + if (countdownInterval) { + clearInterval(countdownInterval); + countdownInterval = null; + } + } else { + onCountdown(remaining); + } + }, 1000); + } + setTimeout(() => { + if (countdownInterval) { + clearInterval(countdownInterval); + countdownInterval = null; + } + onSubmit(); + }, timeout); } }; window.addEventListener('message', onMessage); @@ -136,10 +161,18 @@ export function initVelxioBridge( } catch { /* cross-origin or not ready yet */ } }, 1000); - setTimeout(() => { - clearInterval(pollReady); - if (settled) return; - settled = true; + const cleanup = () => { window.removeEventListener('message', onMessage); + clearInterval(pollReady); + if (countdownInterval) { + clearInterval(countdownInterval); + countdownInterval = null; + } + }; + + setTimeout(() => { + cleanup(); }, 30_000); + + return cleanup; } diff --git a/frontend/src/routes/lesson/[slug]/+page.svelte b/frontend/src/routes/lesson/[slug]/+page.svelte index f6eb324..d16d484 100644 --- a/frontend/src/routes/lesson/[slug]/+page.svelte +++ b/frontend/src/routes/lesson/[slug]/+page.svelte @@ -101,7 +101,14 @@ beforeNavigate(() => { lessonContext.set(null); - if (mgr.velxioBridge) { mgr.velxioBridge.destroy(); mgr.velxioBridge = null; } + if (mgr.velxioCleanup) { + mgr.velxioCleanup(); + mgr.velxioCleanup = null; + } + if (mgr.velxioBridge) { + mgr.velxioBridge.destroy(); + mgr.velxioBridge = null; + } }); let contentEl = $state(null); diff --git a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts index 423fee1..d6464e5 100644 --- a/frontend/src/routes/lesson/[slug]/lesson.svelte.ts +++ b/frontend/src/routes/lesson/[slug]/lesson.svelte.ts @@ -42,6 +42,7 @@ export class LessonManager { velxioSaving = $state(false); velxioError = $state(false); velxioIframe = $state(null); + velxioCleanup = $state<(() => void) | null>(null); showSolution = $state(false); activeTab = $state<'info' | 'exercise' | 'editor' | 'circuit' | 'output' | 'velxio' | 'flowchart'>('info'); @@ -177,6 +178,7 @@ export class LessonManager { this.circuitPassed = false; this.showSolution = false; + if (this.velxioCleanup) { this.velxioCleanup(); this.velxioCleanup = null; } if (this.velxioBridge) { this.velxioBridge.destroy(); this.velxioBridge = null; } this.velxioReady = false; this.velxioError = false; @@ -411,7 +413,11 @@ export class LessonManager { setupVelxioBridge(iframe: HTMLIFrameElement) { this.velxioIframe = iframe; - initVelxioBridge( + if (this.velxioCleanup) { + this.velxioCleanup(); + this.velxioCleanup = null; + } + this.velxioCleanup = initVelxioBridge( iframe, this.data, this.arduinoCircuitKey, @@ -420,7 +426,16 @@ export class LessonManager { this.velxioBridge = bridge; this.velxioReady = true; }, - () => this.handleVelxioSubmit() + () => this.handleVelxioSubmit(), + (msRemaining) => { + this.activeTab = 'output'; + Object.assign(this.velxioOut, { + loading: true, + output: `Kompilasi sukses. Menjalankan simulasi & merekam log serial (${Math.round(msRemaining / 1000)} detik)...`, + error: '', + success: null + }); + } ); } diff --git a/frontend/static/favicon.ico b/frontend/static/favicon.ico new file mode 100644 index 0000000..5c579d4 Binary files /dev/null and b/frontend/static/favicon.ico differ diff --git a/frontend/static/favicon.png b/frontend/static/favicon.png new file mode 100644 index 0000000..a3e6168 Binary files /dev/null and b/frontend/static/favicon.png differ diff --git a/frontend/static/icons/icon-192.png b/frontend/static/icons/icon-192.png new file mode 100644 index 0000000..30625bd Binary files /dev/null and b/frontend/static/icons/icon-192.png differ diff --git a/frontend/static/icons/icon-512.png b/frontend/static/icons/icon-512.png new file mode 100644 index 0000000..6b38f2e Binary files /dev/null and b/frontend/static/icons/icon-512.png differ diff --git a/frontend/static/manifest.json b/frontend/static/manifest.json index 4a1ac43..f1de6a9 100644 --- a/frontend/static/manifest.json +++ b/frontend/static/manifest.json @@ -1,21 +1,32 @@ { - "name": "Elemes LMS", - "short_name": "Elemes", - "start_url": "/", - "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#0d6efd", - "description": "Belajar Pemrograman C", - "icons": [ - { - "src": "/icons/icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/icons/icon-512.png", - "sizes": "512x512", - "type": "image/png" - } - ] + "name": "Elemes LMS", + "short_name": "Elemes", + "description": "Belajar Pemrograman C & Arduino", + "start_url": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#0a0a0a", + "theme_color": "#0d6efd", + "categories": ["education"], + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "Daftar Pelajaran", + "url": "/", + "description": "Lihat semua pelajaran" + } + ] } diff --git a/frontend/static/sw.js b/frontend/static/sw.js new file mode 100644 index 0000000..2ae3d07 --- /dev/null +++ b/frontend/static/sw.js @@ -0,0 +1,130 @@ +// static/sw.js +const CACHE_VERSION = 'elemes-v1'; +const STATIC_CACHE = `${CACHE_VERSION}-static`; +const API_CACHE = `${CACHE_VERSION}-api`; +const ASSET_CACHE = `${CACHE_VERSION}-assets`; + +// Assets yang di-precache (Vite immutable + critical) +const PRECACHE_URLS = [ + '/', + '/manifest.json', +]; + +// Install: precache minimal assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(STATIC_CACHE).then(cache => cache.addAll(PRECACHE_URLS)) + .then(() => self.skipWaiting()) + ); +}); + +// Activate: hapus cache lama +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys + .filter(k => k.startsWith('elemes-') && k !== STATIC_CACHE && k !== API_CACHE && k !== ASSET_CACHE) + .map(k => caches.delete(k)) + ) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Hanya handle GET + if (request.method !== 'GET') return; + + // NetworkOnly: API yang tidak boleh di-cache + if (url.pathname.startsWith('/api/compile') || + url.pathname.startsWith('/api/track-progress') || + url.pathname.startsWith('/api/login') || + url.pathname.startsWith('/api/logout')) { + return; // biarkan browser handle + } + + // CacheFirst: Static assets (Vite immutable dengan hash) + if (url.pathname.startsWith('/_app/immutable/')) { + event.respondWith(cacheFirst(request, STATIC_CACHE, 365)); + return; + } + + // CacheFirst: Gambar lesson (assets) + if (url.pathname.startsWith('/assets/')) { + event.respondWith(cacheFirst(request, ASSET_CACHE, 7)); + return; + } + + // CacheFirst: Font files (immutable) + if (url.hostname === 'fonts.gstatic.com') { + event.respondWith(cacheFirst(request, STATIC_CACHE, 365)); + return; + } + + // NetworkFirst: API lesson data (cache 6 jam) + if (url.pathname.startsWith('/api/lesson/') && url.pathname.endsWith('.json')) { + event.respondWith(networkFirst(request, API_CACHE, 6 * 60)); + return; + } + + // NetworkFirst: API lesson list (cache 1 jam) + if (url.pathname.startsWith('/api/lessons')) { + event.respondWith(networkFirst(request, API_CACHE, 60)); + return; + } + + // StaleWhileRevalidate: HTML pages + if (request.headers.get('accept')?.includes('text/html')) { + event.respondWith(staleWhileRevalidate(request, STATIC_CACHE)); + return; + } + + // StaleWhileRevalidate: Google Fonts CSS, KaTeX + if (url.hostname === 'fonts.googleapis.com' || url.hostname === 'cdn.jsdelivr.net') { + event.respondWith(staleWhileRevalidate(request, STATIC_CACHE)); + return; + } +}); + +// ── Helpers ────────────────────────────────────────────────────── + +async function cacheFirst(request, cacheName, maxAgeDays = 7) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok) cache.put(request, response.clone()); + return response; +} + +async function networkFirst(request, cacheName, maxAgeMinutes = 60) { + const cache = await caches.open(cacheName); + try { + const response = await fetch(request); + if (response.ok) cache.put(request, response.clone()); + return response; + } catch { + const cached = await cache.match(request); + if (cached) return cached; + return new Response(JSON.stringify({ error: 'Offline', cached: false }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } +} + +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + const networkFetch = fetch(request).then(response => { + if (response.ok) cache.put(request, response.clone()); + return response; + }).catch(() => null); + return cached || (await networkFetch) || + new Response('Offline — halaman ini belum pernah dibuka sebelumnya.', { + status: 503, + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }); +} diff --git a/services/lesson_service.py b/services/lesson_service.py index 967ebb0..2f59325 100644 --- a/services/lesson_service.py +++ b/services/lesson_service.py @@ -6,20 +6,42 @@ import os import re import html as html_module from functools import lru_cache +from threading import Lock import markdown as md from config import CONTENT_DIR +_home_cache = {'content': None, 'mtime': -1.0} +_home_lock = Lock() + +_markdown_cache = {} +_markdown_lock = Lock() + -@lru_cache(maxsize=1) def _read_home_md(): """Read home.md and return its content, or empty string if missing.""" path = os.path.join(CONTENT_DIR, "home.md") if not os.path.exists(path): return "" - with open(path, 'r', encoding='utf-8') as f: - return f.read() + try: + current_mtime = os.path.getmtime(path) + except OSError: + return _home_cache.get('content') or "" + + with _home_lock: + if current_mtime != _home_cache['mtime']: + with open(path, 'r', encoding='utf-8') as f: + _home_cache['content'] = f.read() + _home_cache['mtime'] = current_mtime + # Invalidate all downstream caches + find_lesson_file.cache_clear() + get_lessons.cache_clear() + get_lesson_names.cache_clear() + get_lessons_with_learning_objectives.cache_clear() + with _markdown_lock: + _markdown_cache.clear() + return _home_cache['content'] def _parse_lesson_links(home_content): @@ -417,9 +439,18 @@ def _extract_section(content, start_marker, end_marker): return extracted, remaining -@lru_cache(maxsize=32) def render_markdown_content(file_path): """Parse a lesson markdown file and return structured HTML parts as a dictionary.""" + try: + current_mtime = os.path.getmtime(file_path) + except OSError: + current_mtime = 0.0 + + with _markdown_lock: + cached = _markdown_cache.get(file_path) + if cached and cached['mtime'] == current_mtime: + return cached['data'] + with open(file_path, 'r', encoding='utf-8') as f: content = f.read() @@ -586,7 +617,7 @@ def render_markdown_content(file_path): exercise_html = md.markdown(exercise_content, extensions=MD_EXTENSIONS) if exercise_content else "" lesson_info_html = md.markdown(lesson_info, extensions=MD_EXTENSIONS) if lesson_info else "" - return { + parsed_data = { 'lesson_html': lesson_html, 'exercise_html': exercise_html, 'expected_output': expected_output, @@ -615,10 +646,21 @@ def render_markdown_content(file_path): 'slides': slides_html } + with _markdown_lock: + if len(_markdown_cache) >= 128: + _markdown_cache.clear() + _markdown_cache[file_path] = {'data': parsed_data, 'mtime': current_mtime} + + return parsed_data + -@lru_cache(maxsize=1) 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. + + Not cached separately — relies on _read_home_md() mtime-based cache, + which is already fast (1 syscall per request). This avoids the lru_cache + multi-worker stale data problem. + """ home_content = _read_home_md() if not home_content: return ""