From a0d6f2615aff7d8379e6c9abf2a88895e22da725 Mon Sep 17 00:00:00 2001 From: a2nr Date: Thu, 26 Mar 2026 13:52:59 +0700 Subject: [PATCH] feat: Implement anti copy-paste system and enhance asset proxying - Added proxy handling for `/assets/` in `sinau-c-tail.json` to route requests to the asset server. - Enhanced `hooks.server.ts` to support proxying for both `/api/*` and `/assets/*` endpoints, including handling binary content. - Introduced an anti copy-paste system in the lesson page to prevent text selection and copying from lesson content. - Updated `CodeEditor.svelte` to prevent pasting from external sources with multiple layers of protection. - Improved lesson page structure to support floating editor functionality and mobile responsiveness. - Added celebration overlay for successful code completion in the lesson page. - Adjusted `vite.config.ts` to include asset proxy configuration. - Modified `podman-compose.yml` for production deployment with gunicorn and updated network settings. --- config/sinau-c-tail.json | 3 + documentation.md | 35 + frontend/src/hooks.server.ts | 20 +- frontend/src/lib/components/CodeEditor.svelte | 147 +++- .../src/routes/lesson/[slug]/+page.svelte | 710 +++++++++++++++--- frontend/vite.config.ts | 4 + podman-compose.yml | 27 +- 7 files changed, 790 insertions(+), 156 deletions(-) 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}
- -
- -
- + {/if} + + + +
+ + + {#if isMobile} + - -
- - -
- - - {#if data.solution_code} - - {/if} - {data.language_display_name} -
- - -
- (currentCode = val)} - /> -
- - -
- -
- - - {#if data.expected_output} -
- Expected Output -
{data.expected_output}
-
+ {:else if editorFloating && !editorMinimized} + +
+ + { e.stopPropagation(); onResizeStart(e); }} title="Resize">◳ + Code Editor +
+ + +
+
{/if} + + +
+ {#if isMobile} +
+ + +
+ {/if} + + +
+ + + {#if data.solution_code && auth.isLoggedIn && data.lesson_completed} + + {/if} + {data.language_display_name} + {#if !isMobile && !editorFloating} + + {/if} +
+ + +
+ (currentCode = val)} + /> +
+ + +
+ +
+ + + {#if data.expected_output} +
+ Expected Output +
{data.expected_output}
+
+ {/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