diff --git a/config/sinau-c-tail.json b/config/sinau-c-tail.json
index c3607b7..43087d8 100644
--- a/config/sinau-c-tail.json
+++ b/config/sinau-c-tail.json
@@ -9,6 +9,9 @@
"Handlers": {
"/": {
"Proxy": "http://elemes-frontend:3000"
+ },
+ "/assets/": {
+ "Proxy": "http://elemes:5000/assets/"
}
}
}
diff --git a/documentation.md b/documentation.md
index bdd3b80..05c3476 100644
--- a/documentation.md
+++ b/documentation.md
@@ -185,6 +185,41 @@ Semua endpoint Flask diakses via SvelteKit proxy (`/api/*` → Flask `:5000`):
---
+## Anti Copy-Paste System
+
+Sistem berlapis untuk mencegah siswa meng-copy konten pelajaran dan mem-paste kode dari sumber eksternal ke editor.
+
+### Selection & Copy Prevention (Halaman Lesson)
+
+**File:** `frontend/src/routes/lesson/[slug]/+page.svelte`
+
+Mencegah siswa men-select dan meng-copy teks dari konten pelajaran (termasuk code blocks).
+
+| Layer | Mekanisme | Target |
+|-------|-----------|--------|
+| CSS | `user-select: none`, `-webkit-touch-callout: none` | `.lesson-content`, `.lesson-info` |
+| Events | `onselectstart`, `oncopy`, `oncut`, `oncontextmenu` → `preventDefault()` | `.lesson-content`, `.lesson-info` |
+| JS | `selectionchange` + `mouseup` + `touchend` → `getSelection().removeAllRanges()` | Fallback aktif — clear selection jika terjadi di area konten (scoped, tidak mengganggu editor) |
+
+### Paste Prevention (CodeEditor)
+
+**File:** `frontend/src/lib/components/CodeEditor.svelte`
+
+Mencegah siswa mem-paste kode dari sumber eksternal ke code editor. Diaktifkan via prop `noPaste={true}`.
+
+| Layer | Mekanisme | Menangani |
+|-------|-----------|-----------|
+| 1 | `EditorView.domEventHandlers` — `paste`, `drop`, `beforeinput` → `preventDefault()` | Desktop paste, iOS paste |
+| A | `EditorState.transactionFilter` — block `input.paste` + heuristik ukuran (>2 baris atau >20 chars untuk 2 baris) | Standard paste + **GBoard clipboard panel** (paste via IME yang menyamar sebagai `input.type.compose`) |
+| C | `EditorView.clipboardInputFilter` — replace clipboard text → `''` (runtime check) | Standard paste (jika API tersedia) |
+| D | `EditorView.inputHandler` — block multi-line insertion >20 chars | GBoard clipboard via DOM mutations |
+| 2 | DOM capture-phase listeners — `paste`, `copy`, `cut`, `contextmenu`, `drop` → `preventDefault()` | Backup DOM-level |
+| B | `input` event listener — `CM.undo()` jika `insertFromPaste` | Fallback post-hoc revert |
+
+**Limitasi:** GBoard clipboard panel menyuntikkan teks lewat IME composition system (bukan clipboard API), sehingga tidak bisa dibedakan 100% dari ketikan biasa. Heuristik ukuran teks digunakan untuk mendeteksi dan memblokir mayoritas kasus paste, namun paste 1 baris pendek (<20 chars) masih bisa lolos.
+
+---
+
## Status Implementasi
- [x] **Phase 0:** Backend decomposition (monolith → Blueprints + services)
diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts
index dc7e972..56b735b 100644
--- a/frontend/src/hooks.server.ts
+++ b/frontend/src/hooks.server.ts
@@ -17,8 +17,14 @@ function resolveBackend(): string {
const API_BACKEND = resolveBackend();
export const handle: Handle = async ({ event, resolve }) => {
- if (event.url.pathname.startsWith('/api/')) {
- const backendPath = event.url.pathname.replace(/^\/api/, '');
+ // Proxy /api/* and /assets/* to Flask backend
+ const isApi = event.url.pathname.startsWith('/api/');
+ const isAsset = event.url.pathname.startsWith('/assets/');
+
+ if (isApi || isAsset) {
+ const backendPath = isApi
+ ? event.url.pathname.replace(/^\/api/, '')
+ : event.url.pathname; // /assets/* kept as-is
const backendUrl = `${API_BACKEND}${backendPath}${event.url.search}`;
try {
@@ -38,13 +44,15 @@ export const handle: Handle = async ({ event, resolve }) => {
}
const res = await fetch(backendUrl, init);
- const body = await res.text();
+
+ // Use arrayBuffer for binary content (images), text for API JSON
+ const resContentType = res.headers.get('content-type') ?? 'application/json';
+ const isBinary = !resContentType.startsWith('text/') && !resContentType.includes('json');
+ const body = isBinary ? await res.arrayBuffer() : await res.text();
return new Response(body, {
status: res.status,
- headers: {
- 'content-type': res.headers.get('content-type') ?? 'application/json',
- },
+ headers: { 'content-type': resContentType },
});
} catch (err) {
console.error(`API proxy error (${backendUrl}):`, err);
diff --git a/frontend/src/lib/components/CodeEditor.svelte b/frontend/src/lib/components/CodeEditor.svelte
index bbc7a49..32f51f3 100644
--- a/frontend/src/lib/components/CodeEditor.svelte
+++ b/frontend/src/lib/components/CodeEditor.svelte
@@ -6,17 +6,20 @@
code?: string;
language?: string;
readonly?: boolean;
+ noPaste?: boolean;
onchange?: (value: string) => void;
}
- let { code = '', language = 'c', readonly = false, onchange }: Props = $props();
+ let { code = '', language = 'c', readonly = false, noPaste = false, onchange }: Props = $props();
let container: HTMLDivElement;
let view: any;
let ready = $state(false);
+ let lastThemeDark: boolean | undefined;
// Store module references after dynamic import
let CM: any;
+ let cleanupNoPaste: (() => void) | undefined;
async function loadCodeMirror() {
const [viewMod, stateMod, cmdsMod, langMod, autoMod, cppMod, pyMod, themeMod] =
@@ -76,6 +79,86 @@
);
}
+ if (noPaste) {
+ // Layer 1: DOM event handlers (catches standard paste/drop on desktop)
+ exts.push(
+ CM.EditorView.domEventHandlers({
+ paste(event: ClipboardEvent) {
+ event.preventDefault();
+ return true;
+ },
+ drop(event: DragEvent) {
+ event.preventDefault();
+ return true;
+ },
+ // Mobile browsers may use beforeinput with insertFromPaste/Drop
+ beforeinput(event: InputEvent) {
+ if (event.inputType === 'insertFromPaste' ||
+ event.inputType === 'insertFromDrop' ||
+ event.inputType === 'insertFromPasteAsQuotation') {
+ event.preventDefault();
+ return true;
+ }
+ return false;
+ },
+ })
+ );
+
+ // Layer A: Transaction filter — blocks paste at CM6 abstraction level.
+ // 1) Blocks transactions explicitly tagged 'input.paste' / 'input.drop'
+ // (standard long-press → "Paste" on Android, and all desktop paste).
+ // 2) Heuristic for GBoard clipboard panel: GBoard injects clipboard
+ // text through the IME as 'input.type.compose', indistinguishable
+ // from regular typing at the event level. We detect it by checking
+ // for unusually large insertions (multi-line or long single chunk)
+ // that cannot come from normal keyboard input.
+ exts.push(
+ CM.EditorState.transactionFilter.of((tr: any) => {
+ if (tr.isUserEvent('input.paste') || tr.isUserEvent('input.drop')) {
+ return [];
+ }
+ // Heuristic: detect paste disguised as typing via mobile IME
+ if (tr.isUserEvent('input.type') || tr.isUserEvent('input.type.compose')) {
+ let dominated = false;
+ tr.changes.iterChanges(
+ (_fA: number, _tA: number, _fB: number, _tB: number, inserted: any) => {
+ // 3+ lines → definitely paste
+ // 2 lines with >20 chars → likely paste (Enter+indent is ~5-10 chars)
+ if (inserted.lines > 2 || (inserted.lines > 1 && inserted.length > 20)) {
+ dominated = true;
+ }
+ }
+ );
+ if (dominated) return [];
+ }
+ return tr;
+ })
+ );
+
+ // Layer C: Clipboard input filter — replaces clipboard text with ''
+ // before CM6 processes it (available since @codemirror/view 6.17.0)
+ if (CM.EditorView.clipboardInputFilter) {
+ exts.push(
+ CM.EditorView.clipboardInputFilter.of(() => '')
+ );
+ }
+
+ // Layer D: Input handler — intercepts text from DOM mutations.
+ // GBoard clipboard panel injects text via DOM mutations tagged as
+ // 'input.type.compose'. inputHandler fires for these mutations and
+ // can block them if the text looks like paste (multi-line).
+ exts.push(
+ CM.EditorView.inputHandler.of(
+ (_view: any, _from: number, _to: number, text: string) => {
+ if (text.includes('\n') && text.length > 20) {
+ return true; // block: multi-line insertion = paste
+ }
+ return false; // allow normal typing
+ }
+ )
+ );
+ }
+
return exts;
}
@@ -92,24 +175,66 @@
});
}
+ function setupNoPasteListeners() {
+ if (!noPaste || !container) return;
+ const prevent = (e: Event) => { e.preventDefault(); e.stopPropagation(); };
+ container.addEventListener('paste', prevent, true);
+ container.addEventListener('copy', prevent, true);
+ container.addEventListener('cut', prevent, true);
+ container.addEventListener('contextmenu', prevent, true);
+ container.addEventListener('drop', prevent, true);
+
+ // Layer B: Post-hoc paste revert via input event.
+ // The 'input' event fires AFTER content has been inserted, making it
+ // reliable on mobile where beforeinput may be non-cancelable.
+ // If Layer A (transactionFilter) blocks the paste, no input event fires,
+ // so there is no double-undo risk.
+ const revertPaste = (e: Event) => {
+ const ie = e as InputEvent;
+ if (ie.inputType === 'insertFromPaste' ||
+ ie.inputType === 'insertFromDrop' ||
+ ie.inputType === 'insertFromPasteAsQuotation') {
+ if (view && CM) {
+ CM.undo(view);
+ }
+ }
+ };
+ container.addEventListener('input', revertPaste, true);
+
+ cleanupNoPaste = () => {
+ container.removeEventListener('paste', prevent, true);
+ container.removeEventListener('copy', prevent, true);
+ container.removeEventListener('cut', prevent, true);
+ container.removeEventListener('contextmenu', prevent, true);
+ container.removeEventListener('drop', prevent, true);
+ container.removeEventListener('input', revertPaste, true);
+ };
+ }
+
onMount(async () => {
await loadCodeMirror();
+ lastThemeDark = $themeDark;
createEditor();
+ setupNoPasteListeners();
ready = true;
});
onDestroy(() => {
+ cleanupNoPaste?.();
view?.destroy();
});
- // Recreate editor when theme changes
+ // Recreate editor ONLY when theme actually changes (not on ready/container changes)
$effect(() => {
- const _dark = $themeDark;
- if (ready && container && view) {
- const currentCode = view.state.doc.toString();
- code = currentCode;
- createEditor();
- }
+ const dark = $themeDark;
+ if (!ready || !container || !view) return;
+ if (lastThemeDark === dark) return;
+ lastThemeDark = dark;
+ const currentCode = view.state.doc.toString();
+ code = currentCode;
+ createEditor();
+ // Restore focus after theme-driven recreation
+ requestAnimationFrame(() => view?.focus());
});
/** Replace editor content programmatically (e.g. reset / load solution). */
@@ -126,7 +251,7 @@
}
-
+
{#if !ready}
Memuat editor...
{/if}
@@ -138,6 +263,7 @@
border-radius: var(--radius);
overflow: hidden;
font-size: 0.9rem;
+ -webkit-touch-callout: none;
}
.editor-wrapper :global(.cm-editor) {
min-height: 200px;
@@ -155,6 +281,9 @@
font-size: 0.85rem;
background: var(--color-bg-secondary);
}
+ .editor-wrapper.no-paste :global(.cm-content) {
+ -webkit-touch-callout: none;
+ }
@media (max-width: 768px) {
.editor-wrapper :global(.cm-editor) {
min-height: 150px;
diff --git a/frontend/src/routes/lesson/[slug]/+page.svelte b/frontend/src/routes/lesson/[slug]/+page.svelte
index e054dc6..5f4cdc2 100644
--- a/frontend/src/routes/lesson/[slug]/+page.svelte
+++ b/frontend/src/routes/lesson/[slug]/+page.svelte
@@ -9,10 +9,12 @@
// Data from +page.ts load function (SSR + client)
let { data: pageData } = $props();
- let data = $state
(pageData.lesson);
- // Editor state
- let currentCode = $state(data?.initial_code ?? '');
+ // Derive lesson reactively so navigation updates propagate
+ let lesson = $derived(pageData.lesson);
+
+ let data = $state(null);
+ let currentCode = $state('');
let compileOutput = $state('');
let compileError = $state('');
let compiling = $state(false);
@@ -22,13 +24,183 @@
let showSolution = $state(false);
let activeTab = $state<'editor' | 'output'>('editor');
- let editor: CodeEditor;
+ let editor = $state(null);
+ let showCelebration = $state(false);
+
+ // Floating editor state
+ let editorFloating = $state(false);
+ let editorMinimized = $state(false);
+ let isMobile = $state(false);
+ let mobileExpanded = $state(true);
+ let touchStartY = 0;
+ let lessonContentEl = $state();
+ let lessonInfoEl = $state();
+
+ // 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(() => {
+ if (typeof window === 'undefined') return;
+ const mql = window.matchMedia('(max-width: 768px)');
+ isMobile = mql.matches;
+ const handler = (e: MediaQueryListEvent) => {
+ isMobile = e.matches;
+ if (isMobile) {
+ editorFloating = false;
+ editorMinimized = 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 lesson info
+ const inLesson = lessonContentEl?.contains(node);
+ const inInfo = lessonInfoEl?.contains(node);
+ if (inLesson || inInfo) {
+ 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 onSheetTouchStart(e: TouchEvent) {
+ touchStartY = e.touches[0].clientY;
+ }
+
+ function onSheetTouchEnd(e: TouchEvent) {
+ const delta = e.changedTouches[0].clientY - touchStartY;
+ if (delta > 60) mobileExpanded = false;
+ else if (delta < -60) mobileExpanded = true;
+ }
const slug = $derived($page.params.slug);
- // Update data when navigating between lessons (pageData changes)
+ // Sync lesson data when navigating between lessons
$effect(() => {
- const lesson = pageData.lesson;
if (lesson) {
data = lesson;
currentCode = lesson.initial_code ?? '';
@@ -37,9 +209,17 @@
compileSuccess = null;
showSolution = false;
activeTab = 'editor';
+ mobileExpanded = true;
}
});
+ /** Check if student code contains all required key_text keywords. */
+ function checkKeyText(code: string, keyText: string): boolean {
+ if (!keyText.trim()) return true;
+ const keys = keyText.split('\n').map(k => k.trim()).filter(k => k.length > 0);
+ return keys.every(key => code.includes(key));
+ }
+
async function handleRun() {
if (!data) return;
compiling = true;
@@ -56,14 +236,20 @@
compileOutput = res.output;
compileSuccess = true;
- // Check if output matches expected output for auto-completion
- if (data.expected_output && auth.isLoggedIn) {
- const actual = res.output.trim();
- const expected = data.expected_output.trim();
- if (actual === expected) {
- const lessonName = slug.replace('.md', '');
- await trackProgress(auth.token, lessonName);
- data.lesson_completed = 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;
+ }
}
}
} else {
@@ -102,6 +288,16 @@
{data?.lesson_title ?? 'Pelajaran'} - Elemes LMS
+
+{#if showCelebration}
+
+
+
✓
+
Selamat! Latihan Selesai!
+
+
+{/if}
+
{#if data}
@@ -113,18 +309,28 @@
{data.lesson_title}
-
+
{#if data.lesson_info}
-
+ e.preventDefault()}
+ oncopy={(e) => e.preventDefault()}
+ oncontextmenu={(e) => e.preventDefault()}>
Informasi Pelajaran
{@html data.lesson_info}
{/if}
-
-
-
-
+
+
+
+
+
e.preventDefault()}
+ oncopy={(e) => e.preventDefault()}
+ oncut={(e) => e.preventDefault()}
+ oncontextmenu={(e) => e.preventDefault()}>
{@html data.lesson_content}
{#if data.exercise_content}
@@ -135,67 +341,104 @@
{/if}
-
-
-
-
-
@@ -259,6 +502,15 @@
overflow-y: auto;
max-height: 80vh;
padding-right: 0.5rem;
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-touch-callout: none;
+ }
+
+ .lesson-info {
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-touch-callout: none;
}
.prose :global(pre) {
@@ -290,12 +542,23 @@
font-size: 1.1rem;
}
- /* ── Editor area ───────────────────────────────────────── */
+ /* ── Editor area (inline mode) ─────────────────────────── */
.editor-area {
position: sticky;
top: 4rem;
}
+ /* ── Single-column layout ──────────────────────────────── */
+ .lesson-layout.single-col {
+ grid-template-columns: 1fr;
+ }
+ .lesson-content.full-width {
+ max-height: none;
+ padding-right: 0;
+ padding-bottom: 60px;
+ }
+
+ /* ── Toolbar ───────────────────────────────────────────── */
.toolbar {
display: flex;
align-items: center;
@@ -318,11 +581,9 @@
font-weight: 600;
text-transform: uppercase;
}
-
.panel {
margin-bottom: 0.75rem;
}
-
.expected-output {
font-size: 0.8rem;
color: var(--color-text-muted);
@@ -334,6 +595,203 @@
margin-top: 0.5rem;
}
+ /* ── Floating restore button (when minimized) ────────── */
+ .float-restore-btn {
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ z-index: 50;
+ background: var(--color-primary);
+ color: #fff;
+ border: none;
+ border-radius: var(--radius);
+ padding: 0.6rem 1rem;
+ font-size: 0.85rem;
+ font-weight: 600;
+ cursor: pointer;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
+ transition: background 0.15s, transform 0.1s;
+ }
+ .float-restore-btn:hover {
+ background: var(--color-primary-dark);
+ }
+ .float-restore-btn:active {
+ transform: scale(0.95);
+ }
+
+ /* ── Float toggle button ───────────────────────────────── */
+ .btn-float-toggle {
+ background: none;
+ border: 1px solid var(--color-border);
+ border-radius: 4px;
+ padding: 0.2rem 0.5rem;
+ cursor: pointer;
+ font-size: 0.95rem;
+ color: var(--color-text-muted);
+ line-height: 1;
+ }
+ .btn-float-toggle:hover {
+ background: var(--color-bg-secondary);
+ color: var(--color-text);
+ }
+
+ /* ── Panel header (floating & sheet) ───────────────────── */
+ .panel-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--color-bg-secondary);
+ border-bottom: 1px solid var(--color-border);
+ user-select: none;
+ cursor: default;
+ flex-wrap: wrap;
+ }
+ .panel-header.draggable {
+ cursor: grab;
+ }
+ .panel-header.draggable:active {
+ cursor: grabbing;
+ }
+ .panel-title {
+ font-size: 0.85rem;
+ font-weight: 600;
+ flex: 1;
+ }
+ .panel-actions {
+ display: flex;
+ gap: 0.25rem;
+ }
+ .panel-btn {
+ background: none;
+ border: 1px solid var(--color-border);
+ border-radius: 4px;
+ padding: 0.15rem 0.5rem;
+ cursor: pointer;
+ font-size: 0.8rem;
+ color: var(--color-text);
+ line-height: 1;
+ }
+ .panel-btn:hover {
+ background: var(--color-border);
+ }
+
+ /* ── Desktop floating mode ─────────────────────────────── */
+ .editor-area.floating {
+ position: fixed;
+ bottom: 1rem;
+ right: 1rem;
+ top: auto;
+ width: 45vw;
+ min-width: 320px;
+ max-width: 90vw;
+ max-height: 85vh;
+ z-index: 50;
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+ }
+ .resize-handle {
+ cursor: nwse-resize;
+ font-size: 0.9rem;
+ color: var(--color-text-muted);
+ line-height: 1;
+ padding: 0.1rem 0.3rem;
+ border-radius: 3px;
+ }
+ .resize-handle:hover {
+ background: var(--color-border);
+ color: var(--color-text);
+ }
+ .editor-area.floating .editor-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.5rem;
+ min-height: 0;
+ }
+ .editor-area.floating-hidden {
+ display: none !important;
+ }
+
+ /* ── Mobile bottom sheet ───────────────────────────────── */
+ .editor-area.mobile-sheet {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ top: auto;
+ z-index: 50;
+ background: var(--color-bg);
+ 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;
+ }
+ .editor-area.mobile-collapsed {
+ transform: translateY(calc(100% - 48px));
+ }
+ .mobile-sheet .editor-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.5rem;
+ overscroll-behavior: contain;
+ }
+ .sheet-handle {
+ flex-direction: column;
+ border: none;
+ border-bottom: 1px solid var(--color-border);
+ cursor: pointer;
+ width: 100%;
+ color: inherit;
+ font: inherit;
+ text-align: center;
+ }
+ .sheet-handle-bar {
+ width: 36px;
+ height: 4px;
+ background: var(--color-border);
+ border-radius: 2px;
+ margin: 0 auto 0.25rem;
+ }
+
+ /* ── Mobile tabs ───────────────────────────────────────── */
+ .mobile-tabs {
+ display: flex;
+ gap: 0;
+ margin-bottom: 0.5rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius);
+ overflow: hidden;
+ }
+ .tab {
+ flex: 1;
+ padding: 0.5rem;
+ border: none;
+ background: var(--color-bg-secondary);
+ cursor: pointer;
+ font-weight: 500;
+ font-size: 0.85rem;
+ }
+ .tab.active {
+ background: var(--color-primary);
+ color: #fff;
+ }
+
+ /* ── Utility ───────────────────────────────────────────── */
+ .hidden-mobile {
+ display: none;
+ }
+ .editor-body.body-hidden {
+ display: none;
+ }
+
/* ── Footer nav ────────────────────────────────────────── */
.lesson-footer-nav {
display: flex;
@@ -343,46 +801,54 @@
border-top: 1px solid var(--color-border);
}
- /* ── Mobile tabs (hidden on desktop) ───────────────────── */
- .mobile-tabs {
- display: none;
+ /* ── Celebration overlay ──────────────────────────────── */
+ .celebration-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.4);
+ animation: celebFadeIn 0.3s ease-out;
+ pointer-events: none;
}
-
- /* ── Mobile responsive ─────────────────────────────────── */
- @media (max-width: 768px) {
- .lesson-layout {
- grid-template-columns: 1fr;
- }
- .lesson-content {
- max-height: none;
- padding-right: 0;
- }
- .editor-area {
- position: static;
- }
- .mobile-tabs {
- display: flex;
- gap: 0;
- margin-bottom: 0.5rem;
- border: 1px solid var(--color-border);
- border-radius: var(--radius);
- overflow: hidden;
- }
- .tab {
- flex: 1;
- padding: 0.5rem;
- border: none;
- background: var(--color-bg-secondary);
- cursor: pointer;
- font-weight: 500;
- font-size: 0.85rem;
- }
- .tab.active {
- background: var(--color-primary);
- color: #fff;
- }
- .hidden-mobile {
- display: none;
- }
+ .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); }
}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 0baff38..2991feb 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -9,6 +9,10 @@ export default defineConfig({
target: 'http://elemes:5000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
+ },
+ '/assets': {
+ target: 'http://elemes:5000',
+ changeOrigin: true
}
}
}
diff --git a/podman-compose.yml b/podman-compose.yml
index d2f1133..c6c96d3 100644
--- a/podman-compose.yml
+++ b/podman-compose.yml
@@ -10,29 +10,23 @@ services:
- ../assets:/app/assets
env_file:
- ../.env
- networks:
- lms_net:
- ipv4_address: 10.89.100.10
# production
- # command: gunicorn --config gunicorn.conf.py "app:create_app()"
+ command: gunicorn --config gunicorn.conf.py "app:create_app()"
# debug
- command: python app.py
+ # command: python app.py
elemes-frontend:
build: ./frontend
container_name: elemes-frontend
- ports:
- - 3000:3000
+ # ports:
+ # - 3000:3000
environment:
- ORIGIN=http://localhost:3000
- - API_BACKEND=http://10.89.100.10:5000
+ - API_BACKEND=http://elemes:5000
depends_on:
- elemes
- networks:
- lms_net:
- ipv4_address: 10.89.100.11
elemes-ts:
image: docker.io/tailscale/tailscale:latest
@@ -55,13 +49,8 @@ services:
- elemes-frontend
env_file:
- ../.env
- networks:
- lms_net:
- ipv4_address: 10.89.100.12
networks:
- lms_net:
- driver: bridge
- ipam:
- config:
- - subnet: 10.89.100.0/24
+ main_network:
+ drive: bridge
+ network_mode: service:elemes-ts