diff --git a/frontend/src/routes/teacher/content/+page.svelte b/frontend/src/routes/teacher/content/+page.svelte index 3ae1062..b28340a 100644 --- a/frontend/src/routes/teacher/content/+page.svelte +++ b/frontend/src/routes/teacher/content/+page.svelte @@ -9,7 +9,7 @@ import LessonContentView from '$components/LessonContentView.svelte'; import QuizPreviewReadonly from '$components/QuizPreviewReadonly.svelte'; import { ContentEditorManager } from '$services/contentEditor.svelte'; - import { getTree, createFolder, createFile, renameEntry, deleteEntry, uploadAsset } from '$services/contentEditor'; + import { getTree, createFolder, createFile, renameEntry, deleteEntry, uploadAsset, exportBundle, extractBundle, importBundle } from '$services/contentEditor'; import type { TreeNode } from '$services/contentEditor'; import { upsertSection } from '$services/markdown'; @@ -603,6 +603,113 @@ 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 ?? '')); + + // ── Export / Import bundle ────────────────────────────────────── + let importExportBusy = $state(false); + let importProgress = $state<{ done: number; total: number; current: string } | null>(null); + + function flattenTreePaths(nodes: TreeNode[]): string[] { + const out: string[] = []; + const walk = (list: TreeNode[]) => { + for (const n of list) { + if (n.type === 'file') out.push(n.path); + if (n.children) walk(n.children); + } + }; + walk(nodes); + return out; + } + + function showToast(text: string, isError = false) { + const toast = document.createElement('div'); + toast.textContent = text; + toast.style.cssText = `position:fixed;bottom:1rem;left:50%;transform:translateX(-50%);background:${isError ? '#b91c1c' : '#15803d'};color:#fff;padding:6px 16px;border-radius:8px;font-size:0.8rem;z-index:10000;max-width:90vw;`; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 4000); + } + + 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; + } + } + + async function handleImportFile(file: File) { + importExportBusy = true; + importProgress = null; + try { + // 1. Reload tree SEBELUM extract supaya existingPaths fresh + await loadTrees(); + const existing = new Set([ + ...flattenTreePaths(mgr.contentTree), + ...flattenTreePaths(mgr.assetsTree), + ]); + + // 2. Extract zip di browser + const bundle = await extractBundle(file); + if (bundle.invalidEntries.length > 0) { + showToast(`${bundle.invalidEntries.length} entry diabaikan: ${bundle.invalidEntries.slice(0, 5).join(', ')}${bundle.invalidEntries.length > 5 ? '…' : ''}`, true); + } + if (bundle.contentFiles.length + bundle.assetFiles.length === 0) { + showToast('Bundle kosong — tidak ada yang diimport.', true); + return; + } + + // 3. Konfirmasi mode: OK = skip, Cancel = overwrite + const mode = confirm( + `Import ${bundle.contentFiles.length} materi + ${bundle.assetFiles.length} asset?\n\n` + + 'OK = lewati file yang sudah ada\nCancel = timpa file yang sudah ada', + ) ? 'skip' : 'overwrite'; + + // 4. Kirim per-file + const result = await importBundle({ + contentFiles: bundle.contentFiles, + assetFiles: bundle.assetFiles, + mode, + existingPaths: existing, + onProgress: (done, total, current) => { + importProgress = { done, total, current }; + }, + }); + showToast( + result.success + ? `Import selesai: ${result.imported} ditulis, ${result.skipped} dilewati.` + : `Import gagal di ${result.failedPath}: ${result.message}`, + !result.success, + ); + + // 5. Reload tree UI setelah import + await loadTrees(); + } catch (e) { + showToast(`Import gagal: ${e instanceof Error ? e.message : String(e)}`, true); + } finally { + importExportBusy = false; + importProgress = null; + } + } + + function handleImportClick() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.zip,application/zip'; + input.onchange = () => { + const file = input.files?.[0]; + if (file) handleImportFile(file); + }; + input.click(); + } @@ -675,6 +782,11 @@ > {#snippet saveActions()} + + + {#if importProgress} + Impor {importProgress.done}/{importProgress.total} + {/if} {#if mgr.activePath && !isAssetFile}