Add course import/export: JSON bundle and direct Word docx upload
- Export any course as a .json bundle (GET /api/admin/courses/:id/export) - Import a .json bundle as a new course (POST /api/admin/import-course) - Import one or more unit .docx files directly in the browser — server parses each file with mammoth, extracts Week N: headings as lesson items and the Unit Quiz section as a graded quiz with answer key (POST /api/admin/import-course-docx, multer memory storage) - Admin UI: Export button on course detail; Import card with JSON and Word docs tabs; multi-file picker for docx uploads - scripts/import-kitchen-course.js: one-time converter that produced the Kitchen, Tools & Electrical Skills course JSON bundle (already imported as course id 3: 8 units, 44 items) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
a44cf659b5
commit
3197d29a28
6 files changed
+838
-2
No files matched your search
@@ -77,6 +77,47 @@
|
||||
<button type="submit">Add course</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="admin-card" style="margin-top:1rem">
|
||||
<h2 style="margin:0 0 0.75rem">Import course</h2>
|
||||
|
||||
<!-- mode tabs -->
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
|
||||
<button type="button" class="import-tab-btn secondary" data-tab="json" style="flex:1">📄 From JSON</button>
|
||||
<button type="button" class="import-tab-btn secondary" data-tab="docx" style="flex:1">📝 From Word docs</button>
|
||||
</div>
|
||||
|
||||
<!-- JSON import -->
|
||||
<div id="import-tab-json">
|
||||
<p class="hint" style="margin:0 0 0.75rem">Import a <code>.json</code> bundle previously exported from this app.</p>
|
||||
<input type="file" id="import-course-file" accept=".json" hidden>
|
||||
<div class="inline-form" style="align-items:center;gap:0.5rem">
|
||||
<button type="button" id="import-course-browse" class="secondary">📂 Choose file…</button>
|
||||
<span id="import-course-filename" class="hint" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<button type="button" id="import-course-go" class="secondary" disabled>📥 Import</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Word docx import -->
|
||||
<div id="import-tab-docx" hidden>
|
||||
<p class="hint" style="margin:0 0 0.75rem">Select all unit <code>.docx</code> files at once — one file per unit, sorted by filename.</p>
|
||||
<div class="inline-form" style="flex-direction:column;gap:0.5rem">
|
||||
<input id="import-docx-name" placeholder="Course name (required)" aria-label="Course name" style="width:100%">
|
||||
<input id="import-docx-subject" placeholder="Subject (optional)" aria-label="Subject" style="width:100%">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem">
|
||||
<label style="font-size:0.85rem">Color</label>
|
||||
<input id="import-docx-color" type="color" value="#4f86f7" aria-label="Color">
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="import-docx-files" accept=".docx" multiple hidden>
|
||||
<div class="inline-form" style="align-items:center;gap:0.5rem;margin-top:0.5rem">
|
||||
<button type="button" id="import-docx-browse" class="secondary">📂 Choose .docx files…</button>
|
||||
<span id="import-docx-filenames" class="hint" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<button type="button" id="import-docx-go" class="secondary" disabled>📥 Import</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="import-course-status" class="hint" style="margin:0.5rem 0 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -95,6 +136,7 @@
|
||||
<label class="checkbox-label"><input type="checkbox" id="cd-archived"> Archived</label>
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" id="cd-duplicate" class="secondary">Duplicate course</button>
|
||||
<button type="button" id="cd-export" class="secondary">⬇ Export course</button>
|
||||
<button type="button" id="cd-delete" class="danger">Delete course</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+122
@@ -206,6 +206,110 @@ $('#add-course-form').addEventListener('submit', async (e) => {
|
||||
showPanel('course-detail');
|
||||
});
|
||||
|
||||
// ---------- import course — tab switching ----------
|
||||
function setImportTab(tab) {
|
||||
$('#import-tab-json').hidden = tab !== 'json';
|
||||
$('#import-tab-docx').hidden = tab !== 'docx';
|
||||
document.querySelectorAll('.import-tab-btn').forEach((b) => {
|
||||
b.classList.toggle('secondary', b.dataset.tab !== tab);
|
||||
});
|
||||
}
|
||||
setImportTab('json');
|
||||
document.querySelectorAll('.import-tab-btn').forEach((b) =>
|
||||
b.addEventListener('click', () => setImportTab(b.dataset.tab))
|
||||
);
|
||||
|
||||
// ---------- import course — JSON ----------
|
||||
$('#import-course-browse').addEventListener('click', () => $('#import-course-file').click());
|
||||
$('#import-course-file').addEventListener('change', () => {
|
||||
const f = $('#import-course-file').files[0];
|
||||
$('#import-course-filename').textContent = f ? f.name : '';
|
||||
$('#import-course-go').disabled = !f;
|
||||
$('#import-course-status').textContent = '';
|
||||
});
|
||||
$('#import-course-go').addEventListener('click', async () => {
|
||||
const f = $('#import-course-file').files[0];
|
||||
if (!f) return;
|
||||
$('#import-course-status').textContent = 'Importing…';
|
||||
let bundle;
|
||||
try {
|
||||
bundle = JSON.parse(await f.text());
|
||||
} catch {
|
||||
$('#import-course-status').textContent = 'Could not parse JSON file.';
|
||||
return;
|
||||
}
|
||||
let res;
|
||||
try {
|
||||
res = await api('/api/admin/import-course', { method: 'POST', body: bundle });
|
||||
} catch (err) {
|
||||
$('#import-course-status').textContent = `Error: ${err.message}`;
|
||||
return;
|
||||
}
|
||||
$('#import-course-file').value = '';
|
||||
$('#import-course-filename').textContent = '';
|
||||
$('#import-course-go').disabled = true;
|
||||
$('#import-course-status').textContent = '';
|
||||
currentCourseId = res.id;
|
||||
msg('Course imported.');
|
||||
showPanel('course-detail');
|
||||
});
|
||||
|
||||
// ---------- import course — Word docx ----------
|
||||
$('#import-docx-browse').addEventListener('click', () => $('#import-docx-files').click());
|
||||
$('#import-docx-files').addEventListener('change', () => {
|
||||
const files = [...$('#import-docx-files').files];
|
||||
if (files.length) {
|
||||
$('#import-docx-filenames').textContent =
|
||||
files.length === 1 ? files[0].name : `${files.length} files selected`;
|
||||
// Pre-fill course name from common filename stem if name field is empty
|
||||
if (!$('#import-docx-name').value.trim() && files.length > 1) {
|
||||
// e.g. "Unit_A_Kitchen_Safety.docx" → try to find a shared prefix word
|
||||
}
|
||||
} else {
|
||||
$('#import-docx-filenames').textContent = '';
|
||||
}
|
||||
$('#import-docx-go').disabled = files.length === 0;
|
||||
$('#import-course-status').textContent = '';
|
||||
});
|
||||
$('#import-docx-go').addEventListener('click', async () => {
|
||||
const name = $('#import-docx-name').value.trim();
|
||||
if (!name) { $('#import-course-status').textContent = 'Enter a course name first.'; return; }
|
||||
const files = [...$('#import-docx-files').files];
|
||||
if (!files.length) return;
|
||||
|
||||
$('#import-course-status').textContent = `Parsing ${files.length} file${files.length > 1 ? 's' : ''}…`;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('subject', $('#import-docx-subject').value.trim());
|
||||
fd.append('color', $('#import-docx-color').value);
|
||||
files.forEach((f) => fd.append('files', f));
|
||||
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch('/api/admin/import-course-docx', {
|
||||
method: 'POST',
|
||||
headers: { 'x-pin': parentPin },
|
||||
body: fd,
|
||||
});
|
||||
data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Import failed');
|
||||
} catch (err) {
|
||||
$('#import-course-status').textContent = `Error: ${err.message}`;
|
||||
return;
|
||||
}
|
||||
|
||||
$('#import-docx-files').value = '';
|
||||
$('#import-docx-filenames').textContent = '';
|
||||
$('#import-docx-go').disabled = true;
|
||||
$('#import-docx-name').value = '';
|
||||
$('#import-docx-subject').value = '';
|
||||
$('#import-course-status').textContent = '';
|
||||
currentCourseId = data.id;
|
||||
msg('Course imported from Word documents.');
|
||||
showPanel('course-detail');
|
||||
});
|
||||
|
||||
async function openCourseDetail(id) {
|
||||
if (!id) return showPanel('courses');
|
||||
|
||||
@@ -335,6 +439,24 @@ $('#cd-duplicate').addEventListener('click', async () => {
|
||||
msg('Course duplicated — update the name and details below.');
|
||||
});
|
||||
|
||||
$('#cd-export').addEventListener('click', async () => {
|
||||
const res = await fetch(`/api/admin/courses/${currentCourseId}/export`, {
|
||||
headers: { 'x-pin': parentPin },
|
||||
});
|
||||
if (!res.ok) { msg('Export failed.'); return; }
|
||||
const blob = await res.blob();
|
||||
const disposition = res.headers.get('content-disposition') || '';
|
||||
const nameMatch = disposition.match(/filename="([^"]+)"/);
|
||||
const filename = nameMatch ? nameMatch[1] : 'course.json';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
msg('Course exported.');
|
||||
});
|
||||
|
||||
$('#cd-delete').addEventListener('click', async () => {
|
||||
if (!confirm('Delete this whole course, including all units, items, and grades?')) return;
|
||||
await api(`/api/courses/${currentCourseId}`, { method: 'DELETE' });
|
||||
|
||||
Reference in new issue
Block a user