From b75eb06346b598d4d1949bf8e603d737479505cb Mon Sep 17 00:00:00 2001 From: a2nr Date: Fri, 21 Aug 2026 19:03:53 +0700 Subject: [PATCH] feat(content-editor): dockable/floating workspace, mobile view modes, asset preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Floating/dockable editor panel with drag & resize handles - Mobile view modes: hidden → 50% → 100% toggle cycle - Mobile tree-first: show file tree when no file selected, back button to return - Asset file preview with image display and copy-path button - Touch support for drag and resize on mobile - Fix missing closing div in asset preview section --- .../src/lib/actions/floatingPanel.svelte.ts | 72 ++- .../src/routes/teacher/content/+page.svelte | 449 +++++++++++++++--- 2 files changed, 456 insertions(+), 65 deletions(-) diff --git a/frontend/src/lib/actions/floatingPanel.svelte.ts b/frontend/src/lib/actions/floatingPanel.svelte.ts index c704bc8..0670298 100644 --- a/frontend/src/lib/actions/floatingPanel.svelte.ts +++ b/frontend/src/lib/actions/floatingPanel.svelte.ts @@ -31,13 +31,14 @@ export function createFloatingPanel() { return ''; }); - function getPanel(e: MouseEvent): HTMLElement | null { - return (e.currentTarget as HTMLElement).closest('.editor-area') as HTMLElement | null; + function getPanelFromTarget(target: HTMLElement | null): HTMLElement | null { + return target?.closest('.editor-area') as HTMLElement | null; } function onDragStart(e: MouseEvent) { if (!floating) return; - const panel = getPanel(e); + e.preventDefault(); + const panel = getPanelFromTarget(e.currentTarget as HTMLElement); if (!panel) return; dragging = true; const rect = panel.getBoundingClientRect(); @@ -61,10 +62,38 @@ export function createFloatingPanel() { window.addEventListener('mouseup', onUp); } + function onTouchDragStart(e: TouchEvent) { + if (!floating) return; + const panel = getPanelFromTarget(e.currentTarget as HTMLElement); + if (!panel) return; + dragging = true; + const rect = panel.getBoundingClientRect(); + const touch = e.touches[0]; + dragOffset = { x: touch.clientX - rect.left, y: touch.clientY - rect.top }; + if (!size) { + size = { width: rect.width, height: rect.height }; + } + + const onMove = (ev: TouchEvent) => { + if (!dragging) return; + const t = ev.touches[0]; + const newLeft = Math.max(0, Math.min(window.innerWidth - 100, t.clientX - dragOffset.x)); + const newTop = Math.max(0, Math.min(window.innerHeight - 48, t.clientY - dragOffset.y)); + pos = { top: newTop, left: newLeft }; + }; + const onUp = () => { + dragging = false; + window.removeEventListener('touchmove', onMove); + window.removeEventListener('touchend', onUp); + }; + window.addEventListener('touchmove', onMove, { passive: false }); + window.addEventListener('touchend', onUp); + } + function onResizeStart(e: MouseEvent) { if (!floating) return; e.preventDefault(); - const panel = (e.currentTarget as HTMLElement).closest('.editor-area') as HTMLElement; + const panel = getPanelFromTarget(e.currentTarget as HTMLElement); if (!panel) return; const rect = panel.getBoundingClientRect(); const startX = e.clientX; @@ -93,6 +122,39 @@ export function createFloatingPanel() { window.addEventListener('mouseup', onUp); } + function onTouchResizeStart(e: TouchEvent) { + if (!floating) return; + const panel = getPanelFromTarget(e.currentTarget as HTMLElement); + if (!panel) return; + const rect = panel.getBoundingClientRect(); + const touch = e.touches[0]; + const startX = touch.clientX; + const startY = touch.clientY; + const startW = rect.width; + const startH = rect.height; + const startLeft = rect.left; + const startTop = rect.top; + + const onMove = (ev: TouchEvent) => { + const t = ev.touches[0]; + const dx = startX - t.clientX; + const dy = startY - t.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('touchmove', onMove); + window.removeEventListener('touchend', onUp); + }; + window.addEventListener('touchmove', onMove, { passive: false }); + window.addEventListener('touchend', onUp); + } + function toggle() { floating = !floating; minimized = false; @@ -118,6 +180,8 @@ export function createFloatingPanel() { minimize, restore, onDragStart, + onTouchDragStart, onResizeStart, + onTouchResizeStart, }; } diff --git a/frontend/src/routes/teacher/content/+page.svelte b/frontend/src/routes/teacher/content/+page.svelte index d08acda..002213b 100644 --- a/frontend/src/routes/teacher/content/+page.svelte +++ b/frontend/src/routes/teacher/content/+page.svelte @@ -15,11 +15,21 @@ let editorRef = $state(null); + // Mobile: show tree or editor + let mobileShowTree = $state(true); + // Load trees on mount onMount(async () => { await loadTrees(); }); + // When mobile & no file selected, default to tree view + $effect(() => { + if (mgr.isMobile && !mgr.activePath) { + mobileShowTree = true; + } + }); + // Mobile behavior for floating panel $effect(() => { if (mgr.isMobile) { @@ -49,7 +59,31 @@ if (mgr.dirty && !window.confirm('Ada perubahan yang belum disimpan. Lanjut buka file lain?')) { return; } - mgr.loadDraft(path); + if (mgr.treeRoot === 'assets') { + // Asset file: load for preview only, don't go through draft system + mgr.activePath = path; + mgr.body = ''; + mgr.draftId = null; + mgr.dirty = false; + // Clear preview since assets don't have markdown + mgr.previewHtml = ''; + mgr.previewExerciseHtml = ''; + mgr.previewQuizData = []; + mgr.previewError = null; + } else { + mgr.loadDraft(path); + } + if (mgr.isMobile) { + mobileShowTree = false; + mgr.mobileMode = 'h50'; + } + } + + function handleMobileBackToTree() { + if (mgr.dirty && !window.confirm('Ada perubahan yang belum disimpan. Kembali ke tree?')) { + return; + } + mobileShowTree = true; } function handleEditorChange(value: string) { @@ -83,6 +117,9 @@ if (res.success) { await loadTrees(); await mgr.loadDraft(path); + if (mgr.isMobile) { + mobileShowTree = false; + } } else { alert(res.message || 'Gagal membuat file'); } @@ -115,6 +152,7 @@ mgr.activePath = null; mgr.body = ''; mgr.draftId = null; + if (mgr.isMobile) mobileShowTree = true; } } else if (res.needs_force) { if (window.confirm(`Folder "${name}" tidak kosong. Hapus semua isi?`)) { @@ -125,6 +163,7 @@ mgr.activePath = null; mgr.body = ''; mgr.draftId = null; + if (mgr.isMobile) mobileShowTree = true; } } else { alert(forceRes.message || 'Gagal menghapus'); @@ -157,16 +196,20 @@ input.click(); } - function handleCopyAssetPath(path: string) { + function handleCopyAssetPath(e?: MouseEvent) { + const path = mgr.activePath; + if (!path) return; navigator.clipboard.writeText(`/assets/${path}`); const toast = document.createElement('div'); - toast.textContent = 'Path disalin'; + toast.textContent = 'Path disalin ke clipboard'; toast.style.cssText = 'position:fixed;bottom:1rem;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:6px 16px;border-radius:8px;font-size:0.8rem;z-index:10000;'; document.body.appendChild(toast); setTimeout(() => toast.remove(), 1500); } let activeFileName = $derived(mgr.activePath?.split('/').pop() ?? ''); + let isAssetFile = $derived(mgr.treeRoot === 'assets' && mgr.activePath !== null && !mgr.activePath.endsWith('/')); + let isImageFile = $derived(isAssetFile && /\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(mgr.activePath ?? '')); @@ -179,8 +222,11 @@

Halaman ini hanya untuk guru.

{:else} -
- +
+
Memuat preview...
{:else if mgr.previewError}
{mgr.previewError}
+ {:else if isImageFile && mgr.activePath} +
+ {activeFileName} +

/assets/{mgr.activePath}

+
{:else if mgr.previewHtml} + {#if float.floating && !mgr.isMobile} + +
+ ⣿ + Workspace +
+ + +
+
+ {/if} + -
+
+ + {#if mgr.isMobile && !mobileShowTree} + + {/if} + {activeFileName || 'Editor'}
+ + {#if !mgr.isMobile} + {#if float.floating} + + {:else} + + {/if} + {/if} + + {#if !isAssetFile} + {/if} {#if mgr.isMobile}
-
{/if} @@ -259,63 +357,122 @@
- -
-
-
- - + {#if mgr.isMobile && mobileShowTree} + +
+
+
+ + +
+
+ {#if mgr.treeRoot === 'content'} + + {/if} + + {#if mgr.treeRoot === 'assets'} + + {/if} +
-
- {#if mgr.treeRoot === 'content'} - - {/if} - - {#if mgr.treeRoot === 'assets'} - +
+ {#if mgr.treeLoading} +
Memuat...
+ {:else} + type === 'file' ? handleCreateFile(parentPath) : handleCreateFolder(parentPath) : undefined} + onrename={handleRename} + ondelete={handleDelete} + /> {/if}
-
- {#if mgr.treeLoading} -
Memuat...
+ {:else} + + +
+
+
+ + +
+
+ {#if mgr.treeRoot === 'content'} + + {/if} + + {#if mgr.treeRoot === 'assets'} + + {/if} +
+
+
+ {#if mgr.treeLoading} +
Memuat...
+ {:else} + type === 'file' ? handleCreateFile(parentPath) : handleCreateFolder(parentPath) : undefined} + onrename={handleRename} + ondelete={handleDelete} + /> + {/if} +
+
+ + +
+ {#if mgr.activePath} + {#if isImageFile} +
+
+ {activeFileName} +
+
+ {activeFileName} + /assets/{mgr.activePath}
+ +
+
+
+ {:else} + + {/if} {:else} - type === 'file' ? handleCreateFile(parentPath) : handleCreateFolder(parentPath) : undefined} - onrename={handleRename} - ondelete={handleDelete} - /> +
+

Pilih file dari tree untuk mulai mengedit.

+
{/if}
-
- - -
- {#if mgr.activePath} - - {:else} -
-

Pilih file dari tree untuk mulai mengedit.

-
- {/if} -
+ {/if}
@@ -324,6 +481,11 @@ {mgr.lastMessage.text}
{/if} + + {#if float.floating && !mgr.isMobile && !float.minimized} + +
+ {/if}
{/if} @@ -342,6 +504,10 @@ grid-template-columns: 1fr; } + .content-editor-layout.has-floating .preview-panel { + grid-column: 1 / -1; + } + .access-denied { text-align: center; padding: 4rem 2rem; @@ -367,6 +533,18 @@ min-height: 200px; color: var(--color-text-muted); } + .preview-asset { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 1rem; + } + .preview-asset-meta { + font-size: 0.75rem; + color: var(--color-text-muted); + word-break: break-all; + } .preview-error { padding: 1rem; background: #fff5f5; @@ -389,6 +567,61 @@ height: 70vh; } + /* Drag Handle */ + .drag-handle { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 4px 10px; + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); + cursor: grab; + user-select: none; + flex-shrink: 0; + } + .drag-handle:active { + cursor: grabbing; + } + .drag-handle-grip { + color: var(--color-text-muted); + font-size: 0.7rem; + line-height: 1; + letter-spacing: -1px; + } + .drag-handle-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); + flex: 1; + } + .drag-handle-actions { + display: flex; + gap: 2px; + } + .drag-btn { + background: none; + border: 1px solid transparent; + cursor: pointer; + padding: 3px 5px; + border-radius: 4px; + color: var(--color-text-muted); + display: flex; + align-items: center; + transition: all 0.15s; + } + .drag-btn:hover { + background: var(--color-border); + color: var(--color-text); + border-color: var(--color-border); + } + + .draggable-header { + cursor: grab; + } + .draggable-header:active { + cursor: grabbing; + } + .editor-header { display: flex; align-items: center; @@ -396,6 +629,7 @@ padding: 0.5rem 1rem; border-bottom: 1px solid var(--color-border); background: var(--color-bg-secondary); + flex-shrink: 0; } .editor-filename { font-weight: 600; @@ -411,6 +645,21 @@ align-items: center; } + .btn-icon { + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + color: var(--color-text); + display: flex; + align-items: center; + transition: background 0.15s; + } + .btn-icon:hover { + background: var(--color-border); + } + .editor-body { flex: 1; display: flex; @@ -503,6 +752,52 @@ color: var(--color-text-muted); } + /* Asset Preview */ + .asset-preview { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + } + .asset-preview-image { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + overflow: auto; + background: repeating-conic-gradient(var(--color-border) 0% 25%, transparent 0% 50%) 50% / 16px 16px; + } + .asset-preview-image img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: var(--radius); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + .asset-preview-info { + display: flex; + flex-direction: column; + gap: 4px; + padding: 0.5rem 1rem; + border-top: 1px solid var(--color-border); + background: var(--color-bg-secondary); + } + .asset-name { + font-weight: 600; + font-size: 0.85rem; + } + .asset-path { + font-size: 0.75rem; + color: var(--color-text-muted); + word-break: break-all; + } + .asset-actions { + display: flex; + gap: 0.5rem; + margin-top: 4px; + } + /* Status Bar */ .status-bar { padding: 0.4rem 1rem; @@ -510,6 +805,7 @@ font-weight: 500; text-align: center; border-top: 1px solid var(--color-border); + flex-shrink: 0; } .status-bar.success { background: #ebfbee; @@ -520,6 +816,29 @@ color: #c92a2a; } + /* Resize Handle (floating mode) */ + .resize-handle { + position: absolute; + bottom: 0; + left: 0; + width: 18px; + height: 18px; + cursor: nwse-resize; + z-index: 10; + } + .resize-handle::before { + content: ''; + position: absolute; + bottom: 3px; + left: 3px; + width: 10px; + height: 10px; + border-right: 2px solid var(--color-text-muted); + border-bottom: 2px solid var(--color-text-muted); + border-radius: 0 0 3px 0; + opacity: 0.5; + } + /* Mobile modes (reuse lesson.css patterns) */ .editor-area.mobile-sheet { position: fixed; @@ -544,6 +863,17 @@ .editor-area.mobile-h70 { height: 70vh; } .editor-area.mobile-full { height: 100dvh; border-radius: 0; max-height: none; } + /* Mobile tree full-width */ + .tree-panel.mobile-tree-full { + width: 100%; + min-width: 0; + border-right: none; + flex: 1; + } + .tree-panel.mobile-tree-hidden { + display: none; + } + /* Floating mode */ .editor-area.floating { position: fixed; @@ -613,8 +943,5 @@ grid-template-columns: 1fr; padding: 0; } - .tree-panel { - display: none; - } }