From d3acfcf825ff201e7dfe0bd57851ed9d990e4af0 Mon Sep 17 00:00:00 2001 From: a2nr Date: Fri, 27 Mar 2026 16:41:57 +0700 Subject: [PATCH] update green check, button start, de-overwhelm +page, improve mobile ui, add progress page for teacher only, add sessionStorage. --- frontend/package.json | 1 + frontend/src/app.css | 50 ++ frontend/src/hooks.server.ts | 8 +- .../src/lib/actions/floatingPanel.svelte.ts | 123 +++++ frontend/src/lib/actions/highlightCode.ts | 14 + frontend/src/lib/actions/noSelect.ts | 26 + .../lib/components/CelebrationOverlay.svelte | 65 +++ frontend/src/lib/components/CodeEditor.svelte | 44 +- frontend/src/lib/components/Footer.svelte | 18 +- .../src/lib/components/LessonFooterNav.svelte | 31 ++ frontend/src/lib/components/Navbar.svelte | 126 ++++- frontend/src/lib/services/api.ts | 5 +- frontend/src/lib/stores/auth.ts | 4 + frontend/src/lib/stores/lessonContext.ts | 10 + frontend/src/lib/types/auth.ts | 2 + frontend/src/routes/+page.svelte | 3 +- .../src/routes/lesson/[slug]/+page.svelte | 521 ++++++++---------- frontend/src/routes/lesson/[slug]/+page.ts | 5 +- frontend/svelte.config.js | 3 +- podman-compose.yml | 3 + routes/auth.py | 2 + services/lesson_service.py | 4 +- services/token_service.py | 19 + 23 files changed, 762 insertions(+), 325 deletions(-) create mode 100644 frontend/src/lib/actions/floatingPanel.svelte.ts create mode 100644 frontend/src/lib/actions/highlightCode.ts create mode 100644 frontend/src/lib/actions/noSelect.ts create mode 100644 frontend/src/lib/components/CelebrationOverlay.svelte create mode 100644 frontend/src/lib/components/LessonFooterNav.svelte create mode 100644 frontend/src/lib/stores/lessonContext.ts diff --git a/frontend/package.json b/frontend/package.json index b076603..d5449b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/app.css b/frontend/src/app.css index bb5d255..5ccffca 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 56b735b..ad68323 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -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 = { '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); diff --git a/frontend/src/lib/actions/floatingPanel.svelte.ts b/frontend/src/lib/actions/floatingPanel.svelte.ts new file mode 100644 index 0000000..c704bc8 --- /dev/null +++ b/frontend/src/lib/actions/floatingPanel.svelte.ts @@ -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(null); + let size = $state(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, + }; +} diff --git a/frontend/src/lib/actions/highlightCode.ts b/frontend/src/lib/actions/highlightCode.ts new file mode 100644 index 0000000..bbae51f --- /dev/null +++ b/frontend/src/lib/actions/highlightCode.ts @@ -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); + } + }); +} diff --git a/frontend/src/lib/actions/noSelect.ts b/frontend/src/lib/actions/noSelect.ts new file mode 100644 index 0000000..93ce07b --- /dev/null +++ b/frontend/src/lib/actions/noSelect.ts @@ -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); + } + }; +} diff --git a/frontend/src/lib/components/CelebrationOverlay.svelte b/frontend/src/lib/components/CelebrationOverlay.svelte new file mode 100644 index 0000000..d79fa2b --- /dev/null +++ b/frontend/src/lib/components/CelebrationOverlay.svelte @@ -0,0 +1,65 @@ + + +{#if visible} +
+
+
+

Selamat! Latihan Selesai!

+
+
+{/if} + + diff --git a/frontend/src/lib/components/CodeEditor.svelte b/frontend/src/lib/components/CodeEditor.svelte index 32f51f3..23e925d 100644 --- a/frontend/src/lib/components/CodeEditor.svelte +++ b/frontend/src/lib/components/CodeEditor.svelte @@ -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; diff --git a/frontend/src/lib/components/Footer.svelte b/frontend/src/lib/components/Footer.svelte index 0064a48..f63e212 100644 --- a/frontend/src/lib/components/Footer.svelte +++ b/frontend/src/lib/components/Footer.svelte @@ -1,6 +1,14 @@ + +
-

© {new Date().getFullYear()} Elemes LMS — Belajar Pemrograman

+

© {env.PUBLIC_COPYRIGHT_TEXT || `${new Date().getFullYear()} Elemes LMS`}

+ {#if $authIsTeacher} +

Laporan Progress Siswa

+ {/if}
@@ -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; + } diff --git a/frontend/src/lib/components/LessonFooterNav.svelte b/frontend/src/lib/components/LessonFooterNav.svelte new file mode 100644 index 0000000..ea9e247 --- /dev/null +++ b/frontend/src/lib/components/LessonFooterNav.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/frontend/src/lib/components/Navbar.svelte b/frontend/src/lib/components/Navbar.svelte index 1aa3a1d..fd59201 100644 --- a/frontend/src/lib/components/Navbar.svelte +++ b/frontend/src/lib/components/Navbar.svelte @@ -1,6 +1,9 @@ - Elemes LMS + {env.PUBLIC_PAGE_TITLE_SUFFIX || 'Elemes LMS'} {#if data.homeContent} diff --git a/frontend/src/routes/lesson/[slug]/+page.svelte b/frontend/src/routes/lesson/[slug]/+page.svelte index afb67bf..e6685af 100644 --- a/frontend/src/routes/lesson/[slug]/+page.svelte +++ b/frontend/src/routes/lesson/[slug]/+page.svelte @@ -1,10 +1,16 @@