From 8681969c042d7403af1b8a4abd0a76b50dddfef5 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 25 Oct 2026 18:55:23 +0700 Subject: [PATCH] feat(editor): indikator loading CRUD/upload/export + rail tools unified (C1-C4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fitur A — Indikator loading /teacher/content: - treeBusy/treeBusyAction state + withTreeBusy() helper (guard double-submit) - handleCreateFolder/CreateFile/Rename/Delete/Upload/Export dibungkus busy state - tombol tree-action-btn disabled saat operasi berjalan, tombol aktif menampilkan ⏳ - importProgress kini dirender (sebelumnya dead code) di header tree Fitur C — Perbaikan UX editor: - C1: tombol aksi tree dipindah ke kiri, tab Materi/Assets ke kanan (desktop & mobile) - C2: home.md dibuka otomatis saat pertama kali masuk editor - C3: rail tools menggantikan tab 'Fitur & Aksi' (desktop & mobile identik); kartu status file aktif (nama, badge dirty, Simpan/Publish/Edit) pindah ke rail - C4: template baru
di grup layout Catatan: target scroll 'Fitur & Aksi' dihapus; tab tree memakai seluruh lebar saat file tidak punya tab (exercise/quiz/kode). --- .../src/routes/teacher/content/+page.svelte | 755 +++++++----------- 1 file changed, 290 insertions(+), 465 deletions(-) diff --git a/frontend/src/routes/teacher/content/+page.svelte b/frontend/src/routes/teacher/content/+page.svelte index cded6bd..4c6459e 100644 --- a/frontend/src/routes/teacher/content/+page.svelte +++ b/frontend/src/routes/teacher/content/+page.svelte @@ -22,6 +22,12 @@ let circuitEditorRef = $state(null); let velxioEditorRef = $state(null); + // ── Indikator loading operasi tree (CRUD / upload / export) ────── + // Label operasi yang sedang berjalan, mis. 'Membuat folder…'. + let treeBusy = $state(null); + // Key aksi yang sedang berjalan — dipakai untuk menampilkan ⏳ pada tombolnya. + let treeBusyAction = $state(null); + // Content view mode: 'preview' (default rendered view) or 'edit' (inline editor) let contentViewMode = $state<'preview' | 'edit'>('preview'); let editCursorPos = $state(undefined); @@ -31,7 +37,7 @@ // Load trees on mount & setup global shortcut onMount(() => { - loadTrees(); + void openInitialFile(); const handleGlobalKey = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'e') { @@ -112,17 +118,18 @@ let extractedCircuitVelxio = $derived(extractCode(mgr.body, 'VELXIO_CIRCUIT')); // Tab label map for chrome tabs + // Catatan: tab "Fitur & Aksi" dihapus — tool digantikan rail (C3), + // sehingga desktop & mobile memakai satu cara tampil yang sama. let allTabs = $derived([ - { id: 'features', label: 'Fitur & Aksi' }, ...dynamicTabs, { id: 'exercise', label: 'Exercise', show: hasExercise }, { id: 'quiz', label: 'Quiz', show: hasQuiz }, ].filter((t) => t.show !== false)); - // Auto-switch to features tab when switching files + // Reset tab saat pindah file (tab pertama yang tersedia dipilih di bawah) $effect(() => { if (mgr.activePath) { - mgr.activeTab = 'features'; + mgr.activeTab = ''; } }); @@ -130,7 +137,7 @@ $effect(() => { const validIds = allTabs.map((t) => t.id); if (!validIds.includes(mgr.activeTab)) { - mgr.activeTab = 'features'; + mgr.activeTab = validIds[0] ?? ''; } }); @@ -151,6 +158,31 @@ } } + /** Cari node file dengan path persis di tree (recursive). */ + function findTreeNode(nodes: TreeNode[], path: string): TreeNode | null { + for (const n of nodes) { + if (n.path === path) return n; + if (n.children) { + const found = findTreeNode(n.children, path); + if (found) return found; + } + } + return null; + } + + /** + * Saat pertama kali masuk editor: muat tree, lalu buka `home.md` otomatis + * bila ada (file navigasi utama). Bila tidak ada → panel tetap kosong + * ("Pilih file…") seperti sebelumnya. + */ + async function openInitialFile() { + await loadTrees(); + const home = findTreeNode(mgr.contentTree, 'home.md'); + if (home && home.type === 'file') { + await mgr.loadDraft('home.md'); + } + } + function handleSelect(path: string) { if (mgr.dirty && !window.confirm('Ada perubahan yang belum disimpan. Lanjut buka file lain?')) { return; @@ -336,6 +368,16 @@ '> Penjelasan: tulis penjelasan di sini.\n' + '---END_QUIZ_FLASHCARD---\n', }, + { + id: 'details', + label: 'Details', + group: 'layout', + snippet: + '\n
\n' + + 'Judul bagian yang bisa dibuka-tutup\n\n' + + 'Isi konten di dalam lipatan (markdown tetap dirender).\n\n' + + '
\n', + }, { id: 'exercise', label: 'Exercise', @@ -530,6 +572,7 @@ case 'quiz': return 'Pilihan ganda interaktif & pembahasan'; case 'exercise': return 'Blok instruksi tantangan latihan mandiri'; case 'slide': return 'Presentasi carousel slide atau embed Canva'; + case 'details': return 'Bagian lipatan (bisa dibuka-tutup)'; case 'set-c': return 'Bundle C: initial, expected, solution, key'; case 'set-python': return 'Bundle Python dengan verifikasi output'; case 'set-circuit': return 'Rangkaian analog & evaluasi tegangan node'; @@ -559,30 +602,52 @@ } + // ── Busy helper ──────────────────────────────────────────────── + /** + * Jalankan `fn` dengan indikator loading aktif. + * Guard re-entrancy: bila operasi lain sedang berjalan, call diabaikan + * sehingga klik ganda tidak menghasilkan request kedua. + */ + async function withTreeBusy(label: string, action: string, fn: () => Promise) { + if (treeBusy !== null || importExportBusy) return; + treeBusy = label; + treeBusyAction = action; + try { + await fn(); + } finally { + treeBusy = null; + treeBusyAction = null; + } + } + // Tree operations async function handleCreateFolder(parentPath: string) { const name = window.prompt('Nama folder baru:'); if (!name) return; const path = parentPath ? `${parentPath}/${name}` : name; - const res = await createFolder(mgr.treeRoot, path); - if (res.success) { - await loadTrees(); - } else { - alert(res.message || 'Gagal membuat folder'); - } + await withTreeBusy('Membuat folder…', 'create-folder', async () => { + const res = await createFolder(mgr.treeRoot, path); + if (res.success) { + await loadTrees(); + } else { + alert(res.message || 'Gagal membuat folder'); + } + }); } async function handleCreateFile(parentPath: string) { const name = window.prompt('Nama file (.md):'); if (!name) return; const path = parentPath ? `${parentPath}/${name}` : name; - const res = await createFile(path); - if (res.success) { - await loadTrees(); - await mgr.loadDraft(path); - } else { - alert(res.message || 'Gagal membuat file'); - } + await withTreeBusy('Membuat file…', 'create-file', async () => { + const res = await createFile(path); + if (res.success) { + await loadTrees(); + await mgr.loadDraft(path); + } else { + alert(res.message || 'Gagal membuat file'); + } + }); } // File navigasi kritis yang memerlukan konfirmasi ganda @@ -603,15 +668,17 @@ )) return; } - const res = await renameEntry(mgr.treeRoot, oldPath, newPath, PROTECTED_BASENAMES.has(oldName)); - if (res.success) { - await loadTrees(); - if (mgr.activePath === oldPath) { - await mgr.loadDraft(newPath); + await withTreeBusy('Mengubah nama…', 'rename', async () => { + const res = await renameEntry(mgr.treeRoot, oldPath, newPath, PROTECTED_BASENAMES.has(oldName)); + if (res.success) { + await loadTrees(); + if (mgr.activePath === oldPath) { + await mgr.loadDraft(newPath); + } + } else { + alert(res.message || 'Gagal rename'); } - } else { - alert(res.message || 'Gagal rename'); - } + }); } async function handleDelete(path: string) { @@ -623,33 +690,36 @@ `merusak halaman untuk semua siswa dan TIDAK BISA di-undo. Lanjutkan?` )) return; } else if (!window.confirm(`Hapus "${name}"?`)) return; - const res = await deleteEntry(mgr.treeRoot, path, PROTECTED_BASENAMES.has(name)); - if (res.success) { - await loadTrees(); - if (mgr.activePath === path) { - mgr.activePath = null; - mgr.body = ''; - mgr.draftId = null; - mgr.previewSlides = []; - mgr.previewActiveTabs = []; - } - } else if (res.needs_force) { - if (window.confirm(`Folder "${name}" tidak kosong. Hapus semua isi?`)) { - const forceRes = await deleteEntry(mgr.treeRoot, path, PROTECTED_BASENAMES.has(name), true); - if (forceRes.success) { - await loadTrees(); - if (mgr.activePath === path) { - mgr.activePath = null; - mgr.body = ''; - mgr.draftId = null; - } - } else { - alert(forceRes.message || 'Gagal menghapus'); + + await withTreeBusy('Menghapus…', 'delete', async () => { + const res = await deleteEntry(mgr.treeRoot, path, PROTECTED_BASENAMES.has(name)); + if (res.success) { + await loadTrees(); + if (mgr.activePath === path) { + mgr.activePath = null; + mgr.body = ''; + mgr.draftId = null; + mgr.previewSlides = []; + mgr.previewActiveTabs = []; } + } else if (res.needs_force) { + if (window.confirm(`Folder "${name}" tidak kosong. Hapus semua isi?`)) { + const forceRes = await deleteEntry(mgr.treeRoot, path, PROTECTED_BASENAMES.has(name), true); + if (forceRes.success) { + await loadTrees(); + if (mgr.activePath === path) { + mgr.activePath = null; + mgr.body = ''; + mgr.draftId = null; + } + } else { + alert(forceRes.message || 'Gagal menghapus'); + } + } + } else { + alert(res.message || 'Gagal menghapus'); } - } else { - alert(res.message || 'Gagal menghapus'); - } + }); } async function handleUpload() { @@ -660,16 +730,19 @@ const file = input.files?.[0]; if (!file) return; const folder = window.prompt('Folder tujuan (opsional, kosongkan untuk root assets):', '') ?? ''; - const res = await uploadAsset(file, folder); - if (res.success) { - await loadTrees(); - const path = res.path; - if (window.confirm(`Gambar "${file.name}" berhasil diunggah. Salin path ke clipboard?`)) { - navigator.clipboard.writeText(`/assets/${path}`); + await withTreeBusy(`Mengunggah ${file.name}…`, 'upload', async () => { + showToast(`Mengunggah ${file.name}…`); + const res = await uploadAsset(file, folder); + if (res.success) { + await loadTrees(); + const path = res.path; + if (window.confirm(`Gambar "${file.name}" berhasil diunggah. Salin path ke clipboard?`)) { + navigator.clipboard.writeText(`/assets/${path}`); + } + } else { + alert(res.message || 'Gagal mengunggah'); } - } else { - alert(res.message || 'Gagal mengunggah'); - } + }); }; input.click(); } @@ -714,26 +787,29 @@ } async function handleExport() { - importExportBusy = true; - try { - const blob = await exportBundle(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `elemes-bundle-${new Date().toISOString().slice(0, 10)}.zip`; - a.click(); - URL.revokeObjectURL(url); - showToast('Export berhasil — file sedang diunduh.'); - } catch (e) { - showToast(`Export gagal: ${e instanceof Error ? e.message : String(e)}`, true); - } finally { - importExportBusy = false; - } + await withTreeBusy('Menyiapkan zip…', 'export', async () => { + importExportBusy = true; + try { + const blob = await exportBundle(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `elemes-bundle-${new Date().toISOString().slice(0, 10)}.zip`; + a.click(); + URL.revokeObjectURL(url); + showToast('Export berhasil — file sedang diunduh.'); + } catch (e) { + showToast(`Export gagal: ${e instanceof Error ? e.message : String(e)}`, true); + } finally { + importExportBusy = false; + } + }); } async function handleImportFile(file: File) { + if (importExportBusy || treeBusy !== null) return; // guard double-submit importExportBusy = true; - importProgress = null; + importProgress = { done: 0, total: 0, current: 'Menyiapkan import…' }; try { // 1. Reload tree SEBELUM extract supaya existingPaths fresh await loadTrees(); @@ -902,17 +978,104 @@ {/if} {/snippet} - + {#snippet treeActions()} - {#if mgr.treeRoot === 'content'} - - - {:else} - - + {#if importProgress} + + ⏳ {importProgress.total > 0 ? `${importProgress.done}/${importProgress.total}` : '…'} {importProgress.current} + + {:else if treeBusy} + ⏳ {treeBusy} + {/if} + {#if mgr.treeRoot === 'content'} + + + {:else} + + + {/if} + + + {/snippet} + + + {#snippet toolsRail()} + {#if mgr.activePath} +
+ +
+ {isAssetFile ? '🖼️' : '📄'} {activeFileName} + {#if isAssetFile} + /assets/{mgr.activePath} + + {:else} + {mgr.dirty ? '● Belum disimpan' : '✓ Tersimpan'} + + + + {/if} +
+ + {#if !isAssetFile} + +
+ + + +
+ 📐 Layout + {#each FEATURE_TEMPLATES.filter(t => t.group === 'layout') as tpl} + + {/each} +
+ + + + +
+ 🎯 Set Soal + {#each FEATURE_TEMPLATES.filter(t => t.group === 'set_soal') as tpl} + + {/each} +
+ + + + +
+ ⚡ Inline + {#each FEATURE_TEMPLATES.filter(t => t.group === 'inline') as tpl} + + {/each} +
+
+ {/if} +
{/if} - - {/snippet} @@ -960,77 +1123,23 @@ {/if} - - {#if mgr.isMobile && mgr.activePath && !isAssetFile} -
- -
- - - -
- - -
- - - -
- 📐 Layout - {#each FEATURE_TEMPLATES.filter(t => t.group === 'layout') as tpl} - - {/each} -
- - - - -
- 🎯 Set Soal - {#each FEATURE_TEMPLATES.filter(t => t.group === 'set_soal') as tpl} - - {/each} -
- - - - -
- ⚡ Inline - {#each FEATURE_TEMPLATES.filter(t => t.group === 'inline') as tpl} - - {/each} -
-
-
- {/if} + + {@render toolsRail()}
-
+
{#if mgr.isMobile}
+
+ {@render treeActions()} +
-
- {@render treeActions()} -
{#if mgr.treeLoading} @@ -1053,13 +1162,13 @@
+
+ {@render treeActions()} +
-
- {@render treeActions()} -
{#if mgr.treeLoading} @@ -1080,99 +1189,6 @@
- -
-
- {#if mgr.activePath} - {#if isImageFile} - -
-
- 🖼️ Pratinjau Asset -
-
-
- {activeFileName} -
-
- {activeFileName} - /assets/{mgr.activePath} -
- -
-
-
-
- {:else} - -
-
- 📄 {activeFileName} - - {mgr.dirty ? '● Perubahan Belum Disimpan' : '✓ Tersimpan'} - -
-
- - - -
-
- - -
-

📐 Template Layout & Konten

-
- {#each FEATURE_TEMPLATES.filter(t => t.group === 'layout') as tpl} - - {/each} -
-
- - -
-

🎯 Set Soal & Penilaian

-
- {#each FEATURE_TEMPLATES.filter(t => t.group === 'set_soal') as tpl} - - {/each} -
-
- - -
-

⚡ Komponen Interaktif Inline

-
- {#each FEATURE_TEMPLATES.filter(t => t.group === 'inline') as tpl} - - {/each} -
-
- {/if} - {:else} - -
-

👈 Pilih materi di daftar berkas untuk melihat template & aksi dokumen aktif.

-
- {/if} -
-
-
{#if mgr.previewExerciseHtml} @@ -1436,184 +1452,6 @@ min-height: 0; overflow: hidden; } - /* ── Toolbox (Fitur & Aksi) ── */ - .toolbox-container { - flex: 1; - overflow-y: auto; - padding: 1rem; - display: flex; - flex-direction: column; - gap: 1rem; - } - .toolbox-card { - background: var(--color-bg-secondary); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: 0.75rem; - } - .card-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - margin-bottom: 0.5rem; - } - .card-title { - font-size: 0.85rem; - font-weight: 700; - color: var(--color-text); - } - .btn-row { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - } - .btn-action { - background: var(--color-bg); - border: 1px solid var(--color-border); - border-radius: 6px; - padding: 0.45rem 0.75rem; - font-size: 0.8rem; - font-weight: 500; - color: var(--color-text); - cursor: pointer; - transition: all 0.15s; - white-space: nowrap; - } - .btn-action:hover:not(:disabled) { - background: var(--color-border); - border-color: var(--color-text-muted); - } - .btn-action:disabled { - opacity: 0.45; - cursor: not-allowed; - } - .btn-primary { - background: var(--color-primary, #339af0); - border-color: var(--color-primary, #339af0); - color: #fff; - font-weight: 600; - } - .btn-primary:hover:not(:disabled) { - background: #228be6; - border-color: #228be6; - } - .btn-success { - background: #2b8a3e; - border-color: #2b8a3e; - color: #fff; - font-weight: 600; - } - .btn-success:hover:not(:disabled) { - background: #237032; - border-color: #237032; - } - .btn-secondary { - background: var(--color-bg); - border: 1px dashed var(--color-border); - color: var(--color-text-muted); - } - .btn-secondary:hover:not(:disabled) { - background: var(--color-border); - color: var(--color-text); - } - .active-file-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - margin-bottom: 0.5rem; - flex-wrap: wrap; - } - .file-name { - font-size: 0.85rem; - font-weight: 600; - color: var(--color-text); - word-break: break-all; - } - .file-status-badge { - font-size: 0.72rem; - padding: 0.2rem 0.5rem; - border-radius: 4px; - background: #ebfbee; - color: #2b8a3e; - white-space: nowrap; - } - .file-status-badge.dirty { - background: #fff3bf; - color: #e67700; - } - .toolbox-section { - display: flex; - flex-direction: column; - gap: 0.5rem; - } - .section-title { - font-size: 0.78rem; - font-weight: 700; - color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.04em; - } - .template-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 0.5rem; - } - .template-card-btn { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.2rem; - padding: 0.6rem 0.75rem; - background: var(--color-bg); - border: 1px solid var(--color-border); - border-radius: 6px; - cursor: pointer; - text-align: left; - transition: all 0.15s; - } - .template-card-btn:hover { - background: var(--color-border); - border-color: var(--color-text-muted); - } - .tpl-label { - font-size: 0.8rem; - font-weight: 600; - color: var(--color-text); - } - .tpl-desc { - font-size: 0.7rem; - color: var(--color-text-muted); - line-height: 1.3; - } - .toolbox-zero-state { - display: flex; - align-items: center; - justify-content: center; - padding: 2rem 1rem; - color: var(--color-text-muted); - text-align: center; - font-size: 0.85rem; - } - - @media (max-width: 768px) { - .toolbox-container { - padding: 0.75rem; - gap: 0.75rem; - } - .template-grid { - grid-template-columns: repeat(2, 1fr); - } - .btn-row { - flex-direction: column; - } - .btn-action, .btn-primary, .btn-success, .btn-secondary { - width: 100%; - text-align: center; - } - } - .btn-float-toggle { background: none; border: 1px solid var(--color-border); @@ -1726,6 +1564,9 @@ overflow: hidden; min-height: 0; } + /* Tanpa tab (mis. file materi biasa) → tree memakai seluruh lebar */ + .editor-body.no-tabs .tab-panels { display: none; } + .editor-body.no-tabs .tree-panel { flex: 1 1 auto; } /* Tree Panel */ .tree-panel { @@ -1776,6 +1617,7 @@ .tree-actions { display: flex; gap: 2px; + align-items: center; } .tree-action-btn { display: inline-flex; @@ -1872,52 +1714,6 @@ border: none; } - /* 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; @@ -2000,6 +1796,35 @@ border-right: 1px solid var(--color-border); flex-shrink: 0; } + .qt-file-name { + font-size: 0.78rem; + font-weight: 600; + color: var(--color-text); + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .qt-path { + font-size: 0.7rem; + color: var(--color-text-muted); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .qt-dirty-badge { + font-size: 0.68rem; + padding: 0.15rem 0.45rem; + border-radius: 4px; + background: #ebfbee; + color: #2b8a3e; + white-space: nowrap; + } + .qt-dirty-badge.dirty { + background: #fff3bf; + color: #e67700; + } .qt-scroll { display: flex; align-items: center;