update green check, button start, de-overwhelm +page, improve mobile ui, add progress page for teacher only, add sessionStorage.

master
a2nr 2026-03-27 16:41:57 +07:00
parent 614ade6994
commit d3acfcf825
23 changed files with 762 additions and 325 deletions

View File

@ -22,6 +22,7 @@
"@codemirror/lang-python": "^6.1.0",
"@codemirror/theme-one-dark": "^6.1.0",
"codemirror": "^6.0.1",
"highlight.js": "^11.11.0",
"marked": "^15.0.0"
}
}

View File

@ -1,3 +1,6 @@
/* ── highlight.js light theme ─────────────────────────────────── */
@import 'highlight.js/styles/github.css';
/* ── Reset & base ─────────────────────────────────────────────── */
*,
*::before,
@ -32,6 +35,47 @@
--shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
/* ── highlight.js dark theme overrides ───────────────────────── */
[data-theme='dark'] .hljs {
background: #16213e;
color: #c9d1d9;
}
[data-theme='dark'] .hljs-keyword,
[data-theme='dark'] .hljs-selector-tag {
color: #ff7b72;
}
[data-theme='dark'] .hljs-string,
[data-theme='dark'] .hljs-attr {
color: #a5d6ff;
}
[data-theme='dark'] .hljs-comment,
[data-theme='dark'] .hljs-quote {
color: #8b949e;
}
[data-theme='dark'] .hljs-number,
[data-theme='dark'] .hljs-literal {
color: #79c0ff;
}
[data-theme='dark'] .hljs-type,
[data-theme='dark'] .hljs-built_in {
color: #ffa657;
}
[data-theme='dark'] .hljs-title,
[data-theme='dark'] .hljs-function {
color: #d2a8ff;
}
[data-theme='dark'] .hljs-meta {
color: #79c0ff;
}
[data-theme='dark'] .hljs-section {
color: #1f6feb;
font-weight: bold;
}
[data-theme='dark'] .hljs-symbol,
[data-theme='dark'] .hljs-bullet {
color: #f2cc60;
}
html {
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
color: var(--color-text);
@ -99,6 +143,12 @@ pre code {
font-size: 0.85rem;
}
/* ── Responsive images ───────────────────────────────────────── */
img {
max-width: 100%;
height: auto;
}
/* ── Cards ────────────────────────────────────────────────────── */
.card {
background: var(--color-bg);

View File

@ -33,6 +33,8 @@ export const handle: Handle = async ({ event, resolve }) => {
if (contentType) headers['content-type'] = contentType;
const accept = event.request.headers.get('accept');
if (accept) headers['accept'] = accept;
const cookie = event.request.headers.get('cookie');
if (cookie) headers['cookie'] = cookie;
const init: RequestInit = {
method: event.request.method,
@ -50,9 +52,13 @@ export const handle: Handle = async ({ event, resolve }) => {
const isBinary = !resContentType.startsWith('text/') && !resContentType.includes('json');
const body = isBinary ? await res.arrayBuffer() : await res.text();
const resHeaders: Record<string, string> = { 'content-type': resContentType };
const setCookie = res.headers.get('set-cookie');
if (setCookie) resHeaders['set-cookie'] = setCookie;
return new Response(body, {
status: res.status,
headers: { 'content-type': resContentType },
headers: resHeaders,
});
} catch (err) {
console.error(`API proxy error (${backendUrl}):`, err);

View File

@ -0,0 +1,123 @@
/**
* Reactive state & event handlers for a draggable, resizable floating panel.
*/
export interface FloatPos {
top: number;
left: number;
}
export interface FloatSize {
width: number;
height: number;
}
export function createFloatingPanel() {
let floating = $state(false);
let minimized = $state(false);
let dragging = $state(false);
let pos = $state<FloatPos | null>(null);
let size = $state<FloatSize | null>(null);
let dragOffset = { x: 0, y: 0 };
const style = $derived.by(() => {
if (floating && !minimized) {
let s = '';
if (pos) s += `top:${pos.top}px;left:${pos.left}px;bottom:auto;right:auto;`;
if (size) s += `width:${size.width}px;height:${size.height}px;`;
return s;
}
return '';
});
function getPanel(e: MouseEvent): HTMLElement | null {
return (e.currentTarget as HTMLElement).closest('.editor-area') as HTMLElement | null;
}
function onDragStart(e: MouseEvent) {
if (!floating) return;
const panel = getPanel(e);
if (!panel) return;
dragging = true;
const rect = panel.getBoundingClientRect();
dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
if (!size) {
size = { width: rect.width, height: rect.height };
}
const onMove = (ev: MouseEvent) => {
if (!dragging) return;
const newLeft = Math.max(0, Math.min(window.innerWidth - 100, ev.clientX - dragOffset.x));
const newTop = Math.max(0, Math.min(window.innerHeight - 48, ev.clientY - dragOffset.y));
pos = { top: newTop, left: newLeft };
};
const onUp = () => {
dragging = false;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
function onResizeStart(e: MouseEvent) {
if (!floating) return;
e.preventDefault();
const panel = (e.currentTarget as HTMLElement).closest('.editor-area') as HTMLElement;
if (!panel) return;
const rect = panel.getBoundingClientRect();
const startX = e.clientX;
const startY = e.clientY;
const startW = rect.width;
const startH = rect.height;
const startLeft = rect.left;
const startTop = rect.top;
const onMove = (ev: MouseEvent) => {
const dx = startX - ev.clientX;
const dy = startY - ev.clientY;
const newW = Math.max(320, startW + dx);
const newH = Math.max(200, startH + dy);
size = { width: newW, height: newH };
pos = {
left: startLeft - (newW - startW),
top: startTop - (newH - startH),
};
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
function toggle() {
floating = !floating;
minimized = false;
pos = null;
size = null;
}
function minimize() {
minimized = true;
}
function restore() {
minimized = false;
}
return {
get floating() { return floating; },
set floating(v: boolean) { floating = v; },
get minimized() { return minimized; },
set minimized(v: boolean) { minimized = v; },
get style() { return style; },
toggle,
minimize,
restore,
onDragStart,
onResizeStart,
};
}

View File

@ -0,0 +1,14 @@
import hljs from 'highlight.js/lib/core';
import c from 'highlight.js/lib/languages/c';
import python from 'highlight.js/lib/languages/python';
hljs.registerLanguage('c', c);
hljs.registerLanguage('python', python);
export function highlightAllCode(container: HTMLElement) {
container.querySelectorAll('pre code').forEach((block) => {
if (!(block as HTMLElement).dataset.highlighted) {
hljs.highlightElement(block as HTMLElement);
}
});
}

View File

@ -0,0 +1,26 @@
/**
* Svelte action that prevents text selection inside the node.
* Uses both CSS and JS (selectionchange) as a fallback for mobile browsers.
*/
export function noSelect(node: HTMLElement) {
function clearIfInside() {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return;
const anchor = sel.anchorNode;
if (anchor && node.contains(anchor)) {
sel.removeAllRanges();
}
}
document.addEventListener('selectionchange', clearIfInside);
document.addEventListener('mouseup', clearIfInside);
document.addEventListener('touchend', clearIfInside);
return {
destroy() {
document.removeEventListener('selectionchange', clearIfInside);
document.removeEventListener('mouseup', clearIfInside);
document.removeEventListener('touchend', clearIfInside);
}
};
}

View File

@ -0,0 +1,65 @@
<script lang="ts">
let { visible }: { visible: boolean } = $props();
</script>
{#if visible}
<div class="celebration-overlay">
<div class="celebration-content">
<div class="celebration-icon">&#10003;</div>
<p class="celebration-text">Selamat! Latihan Selesai!</p>
</div>
</div>
{/if}
<style>
.celebration-overlay {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
animation: celebFadeIn 0.3s ease-out;
pointer-events: none;
border-radius: inherit;
}
.celebration-content {
text-align: center;
animation: celebPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.celebration-icon {
width: 80px;
height: 80px;
margin: 0 auto 1rem;
background: var(--color-success);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: 700;
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.4);
animation: celebRing 1.5s ease-out;
}
.celebration-text {
font-size: 1.4rem;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
@keyframes celebFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes celebPop {
0% { transform: scale(0.5); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes celebRing {
0% { box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.6); }
70% { box-shadow: 0 0 0 30px rgba(25, 135, 84, 0); }
100% { box-shadow: 0 0 0 0 rgba(25, 135, 84, 0); }
}
</style>

View File

@ -7,19 +7,30 @@
language?: string;
readonly?: boolean;
noPaste?: boolean;
storageKey?: string;
onchange?: (value: string) => void;
}
let { code = '', language = 'c', readonly = false, noPaste = false, onchange }: Props = $props();
let { code = '', language = 'c', readonly = false, noPaste = false, storageKey, onchange }: Props = $props();
let container: HTMLDivElement;
let view: any;
let ready = $state(false);
let lastThemeDark: boolean | undefined;
let lastStorageKey = storageKey;
// Store module references after dynamic import
let CM: any;
let cleanupNoPaste: (() => void) | undefined;
let saveTimeout: any;
function saveToStorage(value: string) {
if (!storageKey) return;
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
sessionStorage.setItem(storageKey, value);
}, 1000);
}
async function loadCodeMirror() {
const [viewMod, stateMod, cmdsMod, langMod, autoMod, cppMod, pyMod, themeMod] =
@ -69,11 +80,13 @@
exts.push(CM.oneDark);
}
if (onchange) {
if (onchange || storageKey) {
exts.push(
CM.EditorView.updateListener.of((update: any) => {
if (update.docChanged) {
onchange(update.state.doc.toString());
const val = update.state.doc.toString();
onchange?.(val);
saveToStorage(val);
}
})
);
@ -166,9 +179,17 @@
if (!CM || !container) return;
if (view) view.destroy();
let initialCode = code;
if (storageKey) {
const saved = sessionStorage.getItem(storageKey);
if (saved !== null) {
initialCode = saved;
}
}
view = new CM.EditorView({
state: CM.EditorState.create({
doc: code,
doc: initialCode,
extensions: buildExtensions(),
}),
parent: container,
@ -237,6 +258,21 @@
requestAnimationFrame(() => view?.focus());
});
// Handle navigation (change of slug/storageKey)
$effect(() => {
if (storageKey !== lastStorageKey) {
lastStorageKey = storageKey;
if (!ready || !view || !storageKey) return;
const saved = sessionStorage.getItem(storageKey);
if (saved !== null) {
setCode(saved);
} else {
setCode(code);
}
}
});
/** Replace editor content programmatically (e.g. reset / load solution). */
export function setCode(newCode: string) {
if (!view) return;

View File

@ -1,6 +1,14 @@
<script lang="ts">
import { authIsTeacher } from '$stores/auth';
import { env } from '$env/dynamic/public';
</script>
<footer class="footer">
<div class="container">
<p>&copy; {new Date().getFullYear()} Elemes LMS &mdash; Belajar Pemrograman</p>
<p>&copy; {env.PUBLIC_COPYRIGHT_TEXT || `${new Date().getFullYear()} Elemes LMS`}</p>
{#if $authIsTeacher}
<p><a href="/progress" class="teacher-link">Laporan Progress Siswa</a></p>
{/if}
</div>
</footer>
@ -14,4 +22,12 @@
color: var(--color-text-muted);
margin-top: auto;
}
.teacher-link {
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
}
.teacher-link:hover {
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,31 @@
<script lang="ts">
let { prevLesson, nextLesson }: {
prevLesson?: { filename: string; title: string } | null;
nextLesson?: { filename: string; title: string } | null;
} = $props();
</script>
<div class="lesson-footer-nav">
{#if prevLesson}
<a href="/lesson/{prevLesson.filename}" class="btn btn-secondary">
&larr; {prevLesson.title}
</a>
{:else}
<span></span>
{/if}
{#if nextLesson}
<a href="/lesson/{nextLesson.filename}" class="btn btn-primary">
{nextLesson.title} &rarr;
</a>
{/if}
</div>
<style>
.lesson-footer-nav {
display: flex;
justify-content: space-between;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
</style>

View File

@ -1,6 +1,9 @@
<script lang="ts">
import { auth, authLoggedIn, authStudentName } from '$stores/auth';
import { theme, themeDark } from '$stores/theme';
import { lessonContext } from '$stores/lessonContext';
import ProgressBadge from '$components/ProgressBadge.svelte';
import { env } from '$env/dynamic/public';
let showLoginModal = $state(false);
let tokenInput = $state('');
@ -16,6 +19,7 @@
if (res.success) {
showLoginModal = false;
tokenInput = '';
location.reload();
} else {
loginError = res.message;
}
@ -29,18 +33,38 @@
<nav class="navbar">
<div class="container navbar-inner">
<a href="/" class="navbar-brand">Elemes LMS</a>
{#if $lessonContext}
<!-- Lesson mode -->
<div class="navbar-left">
<a href="/" class="nav-home-btn" title="Semua Pelajaran">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
</a>
<h1 class="navbar-lesson-title">{$lessonContext.title}</h1>
{#if $lessonContext.completed && $authLoggedIn}
<ProgressBadge completed={true} />
{/if}
</div>
{:else}
<!-- Home mode -->
<a href="/" class="navbar-brand">{env.PUBLIC_APP_BAR_TITLE || 'Elemes LMS'}</a>
{/if}
<div class="navbar-actions">
<button class="btn-icon" onclick={() => theme.toggle()} title="Toggle tema">
{#if $lessonContext?.nextLesson}
<a href="/lesson/{$lessonContext.nextLesson.filename}" class="btn btn-nav-next" title="{$lessonContext.nextLesson.title}">
{$lessonContext.nextLesson.title} &rsaquo;
</a>
{/if}
<button class="btn-icon-sm" onclick={() => theme.toggle()} title="Toggle tema">
{$themeDark ? '\u2600\uFE0F' : '\uD83C\uDF19'}
</button>
{#if $authLoggedIn}
<span class="user-label">{$authStudentName}</span>
<button class="btn btn-danger btn-sm" onclick={() => auth.logout()}>Keluar</button>
<button class="btn btn-danger btn-xs" onclick={() => auth.logout()}>Keluar</button>
{:else}
<button class="btn btn-primary btn-sm" onclick={() => (showLoginModal = true)}>
<button class="btn btn-primary btn-xs" onclick={() => (showLoginModal = true)}>
Masuk
</button>
{/if}
@ -77,7 +101,7 @@
.navbar {
background: var(--color-primary);
color: #fff;
padding: 0.75rem 0;
padding: 0.5rem 0;
position: sticky;
top: 0;
z-index: 100;
@ -86,7 +110,10 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
/* ── Home mode ─────────────────────────────────── */
.navbar-brand {
color: #fff;
font-weight: 700;
@ -97,28 +124,86 @@
color: #fff;
text-decoration: none;
}
/* ── Lesson mode (left section) ────────────────── */
.navbar-left {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex: 1;
}
.nav-home-btn {
color: rgba(255, 255, 255, 0.85);
text-decoration: none;
padding: 0.3rem;
border-radius: 6px;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
display: flex;
align-items: center;
}
.nav-home-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
text-decoration: none;
}
.navbar-lesson-title {
font-size: 1.15rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
margin: 0;
line-height: 1.3;
}
/* ── Right section ─────────────────────────────── */
.navbar-actions {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.5rem;
flex-shrink: 0;
}
.btn-nav-next {
background: rgba(255, 255, 255, 0.2);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
font-weight: 600;
text-decoration: none;
transition: background 0.15s;
white-space: nowrap;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.btn-nav-next:hover {
background: rgba(255, 255, 255, 0.3);
color: #fff;
text-decoration: none;
}
.user-label {
font-size: 0.875rem;
font-size: 0.8rem;
opacity: 0.9;
}
.btn-icon {
.btn-icon-sm {
background: none;
border: none;
cursor: pointer;
font-size: 1.25rem;
font-size: 1rem;
line-height: 1;
padding: 0.2rem;
}
.btn-sm {
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
.btn-xs {
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
}
/* Modal */
/* ── Modal ──────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
@ -159,4 +244,19 @@
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
/* ── Mobile ─────────────────────────────────────── */
@media (max-width: 768px) {
.navbar-lesson-title {
font-size: 0.9rem;
}
.user-label {
display: none;
}
.btn-nav-next {
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
max-width: 100px;
}
}
</style>

View File

@ -48,8 +48,9 @@ export function getLessons(customFetch = fetch) {
return get<{ lessons: Lesson[]; home_content: string }>('/lessons', customFetch);
}
export function getLesson(slug: string, customFetch = fetch) {
return get<LessonContent>(`/lesson/${slug}.json`, customFetch);
export function getLesson(slug: string, customFetch = fetch, token = '') {
const query = token ? `?token=${encodeURIComponent(token)}` : '';
return get<LessonContent>(`/lesson/${slug}.json${query}`, customFetch);
}
export function getKeyText(filename: string, customFetch = fetch) {

View File

@ -15,6 +15,7 @@ const STORAGE_KEY = 'student_token';
export const authToken = writable('');
export const authStudentName = writable('');
export const authLoggedIn = writable(false);
export const authIsTeacher = writable(false);
export const auth = {
/** Current token value (non-reactive). */
@ -33,6 +34,7 @@ export const auth = {
authToken.set(saved);
authStudentName.set(res.student_name);
authLoggedIn.set(true);
authIsTeacher.set(res.is_teacher ?? false);
} else {
localStorage.removeItem(STORAGE_KEY);
}
@ -47,6 +49,7 @@ export const auth = {
authToken.set(inputToken);
authStudentName.set(res.student_name);
authLoggedIn.set(true);
authIsTeacher.set(res.is_teacher ?? false);
localStorage.setItem(STORAGE_KEY, inputToken);
}
return res;
@ -57,6 +60,7 @@ export const auth = {
authToken.set('');
authStudentName.set('');
authLoggedIn.set(false);
authIsTeacher.set(false);
localStorage.removeItem(STORAGE_KEY);
}
};

View File

@ -0,0 +1,10 @@
import { writable } from 'svelte/store';
export interface LessonNavContext {
title: string;
completed: boolean;
prevLesson: { filename: string; title: string } | null;
nextLesson: { filename: string; title: string } | null;
}
export const lessonContext = writable<LessonNavContext | null>(null);

View File

@ -7,11 +7,13 @@ export interface AuthState {
export interface LoginResponse {
success: boolean;
student_name?: string;
is_teacher?: boolean;
message: string;
}
export interface ValidateTokenResponse {
success: boolean;
student_name?: string;
is_teacher?: boolean;
message?: string;
}

View File

@ -1,11 +1,12 @@
<script lang="ts">
import LessonCard from '$components/LessonCard.svelte';
import { env } from '$env/dynamic/public';
let { data } = $props();
</script>
<svelte:head>
<title>Elemes LMS</title>
<title>{env.PUBLIC_PAGE_TITLE_SUFFIX || 'Elemes LMS'}</title>
</svelte:head>
{#if data.homeContent}

View File

@ -1,10 +1,16 @@
<script lang="ts">
import { page } from '$app/stores';
import { beforeNavigate } from '$app/navigation';
import CodeEditor from '$components/CodeEditor.svelte';
import OutputPanel from '$components/OutputPanel.svelte';
import ProgressBadge from '$components/ProgressBadge.svelte';
import { compileCode, trackProgress, getKeyText } from '$services/api';
import CelebrationOverlay from '$components/CelebrationOverlay.svelte';
import { compileCode, trackProgress } from '$services/api';
import { auth } from '$stores/auth';
import { lessonContext } from '$stores/lessonContext';
import { noSelect } from '$actions/noSelect';
import { createFloatingPanel } from '$actions/floatingPanel.svelte';
import { highlightAllCode } from '$actions/highlightCode';
import { tick } from 'svelte';
import type { LessonContent } from '$types/lesson';
// Data from +page.ts load function (SSR + client)
@ -27,96 +33,17 @@
let editor = $state<CodeEditor | null>(null);
let showCelebration = $state(false);
// Floating editor state
let editorFloating = $state(false);
let editorMinimized = $state(false);
// Container refs for syntax highlighting
let contentEl = $state<HTMLElement | null>(null);
let tabsEl = $state<HTMLElement | null>(null);
// Floating editor
const float = createFloatingPanel();
// Mobile state: 'hidden' (only handle bar), 'half' (60%), 'full' (100%)
let isMobile = $state(false);
let mobileExpanded = $state(true);
let mobileMode = $state<'hidden' | 'half' | 'full'>('half');
let touchStartY = 0;
let lessonContentEl = $state<HTMLDivElement>();
let infoTabEl = $state<HTMLDivElement>();
let exerciseTabEl = $state<HTMLDivElement>();
// Drag & resize state for floating panel
let dragging = $state(false);
let dragOffset = { x: 0, y: 0 };
let floatPos = $state<{ top: number; left: number } | null>(null);
let floatSize = $state<{ width: number; height: number } | null>(null);
// Computed inline style for position & size only (visibility handled via CSS class)
let floatStyle = $derived.by(() => {
if (editorFloating && !editorMinimized) {
let s = '';
if (floatPos) s += `top:${floatPos.top}px;left:${floatPos.left}px;bottom:auto;right:auto;`;
if (floatSize) s += `width:${floatSize.width}px;height:${floatSize.height}px;`;
return s;
}
return '';
});
function getFloatingPanel(e: MouseEvent): HTMLElement | null {
return (e.currentTarget as HTMLElement).closest('.editor-area') as HTMLElement | null;
}
function onDragStart(e: MouseEvent) {
if (!editorFloating || isMobile) return;
const panel = getFloatingPanel(e);
if (!panel) return;
dragging = true;
const rect = panel.getBoundingClientRect();
dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
// Capture current size so it's preserved during/after drag
if (!floatSize) {
floatSize = { width: rect.width, height: rect.height };
}
const onMove = (ev: MouseEvent) => {
if (!dragging) return;
const newLeft = Math.max(0, Math.min(window.innerWidth - 100, ev.clientX - dragOffset.x));
const newTop = Math.max(0, Math.min(window.innerHeight - 48, ev.clientY - dragOffset.y));
floatPos = { top: newTop, left: newLeft };
};
const onUp = () => {
dragging = false;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
function onResizeStart(e: MouseEvent) {
if (!editorFloating || isMobile) return;
e.preventDefault();
const panel = (e.currentTarget as HTMLElement).closest('.editor-area') as HTMLElement;
if (!panel) return;
const rect = panel.getBoundingClientRect();
const startX = e.clientX;
const startY = e.clientY;
const startW = rect.width;
const startH = rect.height;
const startLeft = rect.left;
const startTop = rect.top;
const onMove = (ev: MouseEvent) => {
// Handle grows left+up from top-left corner
const dx = startX - ev.clientX;
const dy = startY - ev.clientY;
const newW = Math.max(320, startW + dx);
const newH = Math.max(200, startH + dy);
floatSize = { width: newW, height: newH };
floatPos = {
left: startLeft - (newW - startW),
top: startTop - (newH - startH),
};
};
const onUp = () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}
// Media query detection
$effect(() => {
@ -126,67 +53,18 @@
const handler = (e: MediaQueryListEvent) => {
isMobile = e.matches;
if (isMobile) {
editorFloating = false;
editorMinimized = false;
float.floating = false;
float.minimized = false;
}
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
});
// Selection prevention: clear any text selection inside lesson content
// CSS user-select:none may be bypassed on some mobile browsers, so we
// use JS as a fallback — detect selection via getSelection() and clear it.
$effect(() => {
if (typeof window === 'undefined') return;
function clearIfInProtectedArea() {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) return;
const node = sel.anchorNode;
if (!node) return;
// Only clear if selection is inside lesson content or info/exercise tab panels
const inLesson = lessonContentEl?.contains(node);
const inInfo = infoTabEl?.contains(node);
const inExercise = exerciseTabEl?.contains(node);
if (inLesson || inInfo || inExercise) {
sel.removeAllRanges();
}
}
// selectionchange: fires during selection (real-time)
document.addEventListener('selectionchange', clearIfInProtectedArea);
// mouseup: backup for desktop (fires after mouse release)
document.addEventListener('mouseup', clearIfInProtectedArea);
// touchend: backup for mobile (fires after long-press select)
document.addEventListener('touchend', clearIfInProtectedArea);
return () => {
document.removeEventListener('selectionchange', clearIfInProtectedArea);
document.removeEventListener('mouseup', clearIfInProtectedArea);
document.removeEventListener('touchend', clearIfInProtectedArea);
};
});
function toggleFloat() {
editorFloating = !editorFloating;
editorMinimized = false;
floatPos = null;
floatSize = null;
}
function minimizeFloat() {
editorMinimized = true;
}
function restoreFloat() {
editorMinimized = false;
}
function toggleMobileSheet() {
mobileExpanded = !mobileExpanded;
function cycleMobileSheet() {
if (mobileMode === 'hidden') mobileMode = 'half';
else if (mobileMode === 'half') mobileMode = 'full';
else mobileMode = 'hidden';
}
function onSheetTouchStart(e: TouchEvent) {
@ -195,8 +73,15 @@
function onSheetTouchEnd(e: TouchEvent) {
const delta = e.changedTouches[0].clientY - touchStartY;
if (delta > 60) mobileExpanded = false;
else if (delta < -60) mobileExpanded = true;
if (delta > 60) {
// Swipe down: full→half→hidden
if (mobileMode === 'full') mobileMode = 'half';
else mobileMode = 'hidden';
} else if (delta < -60) {
// Swipe up: hidden→half→full
if (mobileMode === 'hidden') mobileMode = 'half';
else mobileMode = 'full';
}
}
const slug = $derived($page.params.slug);
@ -213,7 +98,30 @@
if (lesson.lesson_info) activeTab = 'info';
else if (lesson.exercise_content) activeTab = 'exercise';
else activeTab = 'editor';
mobileExpanded = true;
mobileMode = 'half';
// Populate navbar context
lessonContext.set({
title: lesson.lesson_title,
completed: lesson.lesson_completed,
prevLesson: lesson.prev_lesson,
nextLesson: lesson.next_lesson
});
}
});
// Clear lesson context when leaving page
beforeNavigate(() => {
lessonContext.set(null);
});
// Apply syntax highlighting after content renders
$effect(() => {
if (data) {
tick().then(() => {
if (contentEl) highlightAllCode(contentEl);
if (tabsEl) highlightAllCode(tabsEl);
});
}
});
@ -240,20 +148,26 @@
compileOutput = res.output;
compileSuccess = true;
// Check completion: output must match AND code must contain all key_text keywords
if (data.expected_output) {
const outputMatch = res.output.trim() === data.expected_output.trim();
const keyTextMatch = checkKeyText(code, data.key_text ?? '');
if (outputMatch && keyTextMatch) {
// Celebration for everyone
showCelebration = true;
setTimeout(() => (showCelebration = false), 3000);
// Track progress only for logged-in users
if (auth.isLoggedIn) {
const lessonName = slug.replace('.md', '');
await trackProgress(auth.token, lessonName);
data.lesson_completed = true;
lessonContext.update(ctx => ctx ? { ...ctx, completed: true } : ctx);
}
// Auto-show solution after celebration
if (data.solution_code) {
showSolution = true;
editor?.setCode(data.solution_code);
}
setTimeout(() => {
showCelebration = false;
activeTab = 'editor';
}, 3000);
}
}
} else {
@ -293,33 +207,42 @@
</svelte:head>
{#if data}
<!-- Navigation breadcrumb -->
<div class="lesson-nav">
<a href="/">&larr; Semua Pelajaran</a>
{#if data.lesson_completed}
<ProgressBadge completed={true} />
{/if}
</div>
<h1 class="lesson-title">{data.lesson_title}</h1>
<!-- Main content area -->
<div class="lesson-layout" class:single-col={editorFloating || isMobile}>
<div class="lesson-layout" class:single-col={float.floating || isMobile}>
<!-- Left: Lesson content (selection & copy prevention) -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="lesson-content" bind:this={lessonContentEl}
<div class="lesson-content" bind:this={contentEl} use:noSelect
role="region" aria-label="Konten pelajaran"
class:full-width={editorFloating || isMobile}
class:full-width={float.floating || isMobile}
onselectstart={(e) => e.preventDefault()}
oncopy={(e) => e.preventDefault()}
oncut={(e) => e.preventDefault()}
oncontextmenu={(e) => e.preventDefault()}>
<div class="prose">{@html data.lesson_content}</div>
<!-- All lessons list -->
{#if data.ordered_lessons?.length}
<div class="all-lessons">
<h3 class="all-lessons-heading">Semua Pelajaran</h3>
<div class="all-lessons-list">
{#each data.ordered_lessons as lesson (lesson.filename)}
<a href="/lesson/{lesson.filename}"
class="lesson-item"
class:lesson-item-active={lesson.filename === slug}>
{#if lesson.completed}
<span class="lesson-check">&#10003;</span>
{/if}
<span class="lesson-item-title">{lesson.title}</span>
</a>
{/each}
</div>
</div>
{/if}
</div>
<!-- Floating restore button (visible when minimized) -->
{#if editorFloating && editorMinimized && !isMobile}
<button type="button" class="float-restore-btn" onclick={restoreFloat}
{#if float.floating && float.minimized && !isMobile}
<button type="button" class="float-restore-btn" onclick={float.restore}
title="Tampilkan Code Editor">
&#9654; Editor
</button>
@ -328,31 +251,33 @@
<!-- Editor + Output -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="editor-area"
class:floating={editorFloating && !isMobile && !editorMinimized}
class:floating-hidden={editorFloating && editorMinimized && !isMobile}
class:floating={float.floating && !isMobile && !float.minimized}
class:floating-hidden={float.floating && float.minimized && !isMobile}
class:mobile-sheet={isMobile}
class:mobile-collapsed={isMobile && !mobileExpanded}
style={floatStyle}>
class:mobile-hidden={isMobile && mobileMode === 'hidden'}
class:mobile-half={isMobile && mobileMode === 'half'}
class:mobile-full={isMobile && mobileMode === 'full'}
style={float.style}>
<!-- Panel header -->
{#if isMobile}
<button class="panel-header sheet-handle"
ontouchstart={onSheetTouchStart}
ontouchend={onSheetTouchEnd}
onclick={toggleMobileSheet}>
onclick={cycleMobileSheet}>
<div class="sheet-handle-bar"></div>
<span class="panel-title">Workspace</span>
</button>
{:else if editorFloating && !editorMinimized}
{:else if float.floating && !float.minimized}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="panel-header draggable" onmousedown={onDragStart}>
<div class="panel-header draggable" onmousedown={float.onDragStart}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="resize-handle" onmousedown={(e) => { e.stopPropagation(); onResizeStart(e); }} title="Resize">&#x25F3;</span>
<span class="resize-handle" onmousedown={(e) => { e.stopPropagation(); float.onResizeStart(e); }} title="Resize">&#x25F3;</span>
<span class="panel-title">Workspace</span>
<div class="panel-actions">
<button type="button" class="panel-btn" onclick={minimizeFloat}
<button type="button" class="panel-btn" onclick={float.minimize}
title="Minimize">▽</button>
<button type="button" class="panel-btn" onclick={toggleFloat}
<button type="button" class="panel-btn" onclick={float.toggle}
title="Dock editor">⊡</button>
</div>
</div>
@ -360,14 +285,14 @@
<div class="panel-header">
<span class="panel-title">Workspace</span>
<div class="panel-actions">
<button type="button" class="btn-float-toggle" onclick={toggleFloat} title="Float editor">&#x229E;</button>
<button type="button" class="btn-float-toggle" onclick={float.toggle} title="Float editor">&#x229E;</button>
</div>
</div>
{/if}
<!-- Editor body -->
<div class="editor-body" class:body-hidden={isMobile && !mobileExpanded}>
<!-- Tabs (shared 4-tab for mobile & desktop) -->
<div class="editor-body" bind:this={tabsEl} class:body-hidden={isMobile && mobileMode === 'hidden'}>
<!-- Tabs -->
<div class="panel-tabs">
{#if data.lesson_info}
<button class="tab" class:active={activeTab === 'info'}
@ -386,26 +311,26 @@
<!-- Info tab panel -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tab-panel" class:tab-hidden={activeTab !== 'info'}
bind:this={infoTabEl}
use:noSelect
onselectstart={(e) => e.preventDefault()}
oncopy={(e) => e.preventDefault()}
oncontextmenu={(e) => e.preventDefault()}>
{#if data.lesson_info}
<div class="info-content">{@html data.lesson_info}</div>
<div class="tab-content">{@html data.lesson_info}</div>
{/if}
</div>
<!-- Exercise tab panel -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tab-panel" class:tab-hidden={activeTab !== 'exercise'}
bind:this={exerciseTabEl}
use:noSelect
onselectstart={(e) => e.preventDefault()}
oncopy={(e) => e.preventDefault()}
oncontextmenu={(e) => e.preventDefault()}>
{#if data.exercise_content}
<div class="exercise-section">
<h2>Latihan</h2>
<div class="prose">{@html data.exercise_content}</div>
<div class="tab-content">
<h2 class="tab-heading">Latihan</h2>
{@html data.exercise_content}
</div>
{/if}
</div>
@ -414,7 +339,7 @@
<div class="tab-panel" class:tab-hidden={activeTab !== 'editor'}>
<div class="toolbar">
<button type="button" class="btn btn-success" onclick={handleRun} disabled={compiling}>
{compiling ? 'Compiling...' : '&#9654; Run'}
{compiling ? 'Compiling...' : '\u25B6 Run'}
</button>
<button type="button" class="btn btn-secondary" onclick={handleReset}>Reset</button>
{#if data.solution_code && auth.isLoggedIn && data.lesson_completed}
@ -431,7 +356,8 @@
code={currentCode}
language={data.language}
noPaste={true}
onchange={(val) => (currentCode = val)}
storageKey={showSolution ? undefined : `elemes_draft_${slug}`}
onchange={(val) => { if (!showSolution) currentCode = val; }}
/>
</div>
@ -454,64 +380,61 @@
</div>
</div>
<!-- Celebration overlay (scoped to editor-area) -->
{#if showCelebration}
<div class="celebration-overlay">
<div class="celebration-content">
<div class="celebration-icon">&#10003;</div>
<p class="celebration-text">Selamat! Latihan Selesai!</p>
</div>
</div>
{/if}
<CelebrationOverlay visible={showCelebration} />
</div>
</div>
<!-- Prev / Next navigation -->
<div class="lesson-footer-nav">
{#if data.prev_lesson}
<a href="/lesson/{data.prev_lesson.filename}" class="btn btn-secondary">
&larr; {data.prev_lesson.title}
</a>
{:else}
<span></span>
{/if}
{#if data.next_lesson}
<a href="/lesson/{data.next_lesson.filename}" class="btn btn-primary">
{data.next_lesson.title} &rarr;
</a>
{/if}
</div>
{/if}
<style>
.lesson-nav {
display: flex;
align-items: center;
justify-content: space-between;
.tab-content {
font-size: 0.85rem;
padding: 0.75rem 0.5rem;
line-height: 1.65;
}
.tab-content :global(pre) {
background: var(--color-bg-secondary);
padding: 0.75rem;
border-radius: var(--radius);
overflow-x: auto;
}
.tab-content :global(code) {
font-family: var(--font-mono);
font-size: 0.8rem;
}
.tab-content :global(p) {
margin-bottom: 0.75rem;
}
.tab-content :global(h2),
.tab-content :global(h3) {
margin-top: 1.25rem;
margin-bottom: 0.5rem;
font-size: 0.85rem;
}
.lesson-title {
font-size: 1.5rem;
margin-bottom: 1rem;
.tab-content :global(ul),
.tab-content :global(ol) {
margin-bottom: 0.75rem;
padding-left: 1.5rem;
}
.info-content {
font-size: 0.85rem;
.tab-content :global(li) {
margin-bottom: 0.25rem;
}
.tab-heading {
color: var(--color-primary);
font-size: 1.1rem;
margin-top: 0;
}
/* ── Two-column layout ─────────────────────────────────── */
.lesson-layout {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 3fr 2fr;
gap: 1.5rem;
align-items: start;
}
.lesson-content {
overflow-y: auto;
max-height: 80vh;
max-height: 90vh;
padding-right: 0.5rem;
-webkit-user-select: none;
user-select: none;
@ -537,18 +460,68 @@
margin-bottom: 0.5rem;
}
.exercise-section {
padding-top: 0.5rem;
/* ── All lessons list ──────────────────────────────────── */
.all-lessons {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
}
.exercise-section h2 {
color: var(--color-primary);
font-size: 1.1rem;
.all-lessons-heading {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.5rem;
}
.all-lessons-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.lesson-item {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.6rem;
border-radius: 6px;
font-size: 0.82rem;
color: var(--color-text);
text-decoration: none;
transition: background 0.12s;
}
.lesson-item:hover {
background: var(--color-bg-secondary);
text-decoration: none;
color: var(--color-text);
}
.lesson-item-active {
background: var(--color-primary);
color: #fff;
font-weight: 600;
}
.lesson-item-active:hover {
background: var(--color-primary-dark);
color: #fff;
}
.lesson-check {
color: var(--color-success);
font-size: 0.75rem;
flex-shrink: 0;
}
.lesson-item-active .lesson-check {
color: #fff;
}
.lesson-item-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Editor area (docked mode — styled like floating) ──── */
/* ── Editor area (docked mode) ──────────────────────────── */
.editor-area {
position: sticky;
top: 4rem;
top: 3.5rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
@ -612,7 +585,7 @@
margin-top: 0.5rem;
}
/* ── Floating restore button (when minimized) ────────── */
/* ── Floating restore button ────────────────────────────── */
.float-restore-btn {
position: fixed;
bottom: 1rem;
@ -652,7 +625,7 @@
color: var(--color-text);
}
/* ── Panel header (floating & sheet) ───────────────────── */
/* ── Panel header ───────────────────────────────────────── */
.panel-header {
display: flex;
align-items: center;
@ -740,14 +713,24 @@
border-top: 2px solid var(--color-primary);
border-radius: 12px 12px 0 0;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
max-height: 70vh;
display: flex;
flex-direction: column;
transition: transform 0.3s ease;
transition: max-height 0.3s ease, transform 0.3s ease;
}
.editor-area.mobile-collapsed {
.editor-area.mobile-hidden {
max-height: 100vh;
transform: translateY(calc(100% - 48px));
}
.editor-area.mobile-half {
max-height: 60vh;
transform: translateY(0);
}
.editor-area.mobile-full {
max-height: calc(100vh - 3rem);
top: 3rem;
border-radius: 0;
transform: translateY(0);
}
.mobile-sheet .editor-body {
overscroll-behavior: contain;
}
@ -783,6 +766,7 @@
padding: 0.5rem;
border: none;
background: var(--color-bg-secondary);
color: var(--color-text);
cursor: pointer;
font-weight: 500;
font-size: 0.85rem;
@ -805,65 +789,4 @@
.editor-body.body-hidden {
display: none;
}
/* ── Footer nav ────────────────────────────────────────── */
.lesson-footer-nav {
display: flex;
justify-content: space-between;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
/* ── Celebration overlay (scoped to editor-area) ─────── */
.celebration-overlay {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
animation: celebFadeIn 0.3s ease-out;
pointer-events: none;
border-radius: inherit;
}
.celebration-content {
text-align: center;
animation: celebPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.celebration-icon {
width: 80px;
height: 80px;
margin: 0 auto 1rem;
background: var(--color-success);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: 700;
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.4);
animation: celebRing 1.5s ease-out;
}
.celebration-text {
font-size: 1.4rem;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
@keyframes celebFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes celebPop {
0% { transform: scale(0.5); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes celebRing {
0% { box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.6); }
70% { box-shadow: 0 0 0 30px rgba(25, 135, 84, 0); }
100% { box-shadow: 0 0 0 0 rgba(25, 135, 84, 0); }
}
</style>

View File

@ -2,6 +2,9 @@ import { getLesson } from '$services/api';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, fetch }) => {
const lesson = await getLesson(params.slug, fetch);
const token = typeof window !== 'undefined'
? localStorage.getItem('student_token') ?? ''
: '';
const lesson = await getLesson(params.slug, fetch, token);
return { lesson };
};

View File

@ -13,7 +13,8 @@ const config = {
$components: 'src/lib/components',
$stores: 'src/lib/stores',
$services: 'src/lib/services',
$types: 'src/lib/types'
$types: 'src/lib/types',
$actions: 'src/lib/actions'
}
}
};

View File

@ -25,6 +25,9 @@ services:
environment:
- ORIGIN=http://localhost:3000
- API_BACKEND=http://elemes:5000
- PUBLIC_APP_BAR_TITLE=${APP_BAR_TITLE}
- PUBLIC_COPYRIGHT_TEXT=${COPYRIGHT_TEXT}
- PUBLIC_PAGE_TITLE_SUFFIX=${PAGE_TITLE_SUFFIX}
depends_on:
- elemes

View File

@ -24,6 +24,7 @@ def login():
response = jsonify({
'success': True,
'student_name': student_info['student_name'],
'is_teacher': student_info.get('is_teacher', False),
'message': 'Login successful',
})
response.set_cookie(
@ -67,6 +68,7 @@ def validate_token_route():
return jsonify({
'success': True,
'student_name': student_info['student_name'],
'is_teacher': student_info.get('is_teacher', False),
})
else:
return jsonify({'success': False, 'message': 'Invalid token'})

View File

@ -185,7 +185,7 @@ def get_ordered_lessons_with_learning_objectives(progress=None):
# Markdown rendering
# ---------------------------------------------------------------------------
MD_EXTENSIONS = ['fenced_code', 'codehilite', 'tables', 'nl2br', 'toc']
MD_EXTENSIONS = ['fenced_code', 'tables', 'nl2br', 'toc']
def _extract_section(content, start_marker, end_marker):
@ -258,4 +258,4 @@ def render_home_content():
parts = home_content.split('---Available_Lessons---')
main_content = parts[0] if parts else home_content
return md.markdown(main_content, extensions=['fenced_code', 'codehilite', 'tables'])
return md.markdown(main_content, extensions=['fenced_code', 'tables'])

View File

@ -9,6 +9,24 @@ import os
from config import TOKENS_FILE
def get_teacher_token():
"""Return the teacher token (first data row in CSV)."""
if not os.path.exists(TOKENS_FILE):
return None
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as csvfile:
reader = csv.DictReader(csvfile, delimiter=';')
for row in reader:
return row['token']
return None
def is_teacher_token(token):
"""Check if the given token belongs to the teacher (first row)."""
return token == get_teacher_token()
def validate_token(token):
"""Validate if a token exists in the CSV file and return student info."""
if not os.path.exists(TOKENS_FILE):
@ -21,6 +39,7 @@ def validate_token(token):
return {
'token': row['token'],
'student_name': row['nama_siswa'],
'is_teacher': is_teacher_token(token),
}
return None