// ---------- tiny helpers ---------- const $ = (sel) => document.querySelector(sel); const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]) ); sessionStorage.removeItem('pin'); let parentPin = null; async function api(path, opts = {}) { const headers = { 'Content-Type': 'application/json' }; if (parentPin) headers['x-pin'] = parentPin; const res = await fetch(path, { method: opts.method || 'GET', headers, body: opts.body ? JSON.stringify(opts.body) : undefined, }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Request failed'); return data; } let _toastTimer = null; function msg(text, type = 'success') { const el = $('#toast'); el.textContent = text; el.className = type === 'error' ? 'toast-error' : ''; el.hidden = false; clearTimeout(_toastTimer); _toastTimer = setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => { el.hidden = true; el.classList.remove('toast-out'); }, 400); }, 3000); } const TYPE_ICON = { lesson: 'π', assignment: 'π', quiz: 'β', matching: 'π€', crossword: 'β¬', spelling_practice: 'βοΈ', spelling_test: 'β', flashcards: 'ποΈ', }; const TYPE_LABEL = { lesson: 'Lesson', assignment: 'Assignment', quiz: 'Quiz', matching: 'Matching', crossword: 'Crossword', spelling_practice: 'Spelling Practice', spelling_test: 'Spelling Test', flashcards: 'Flashcards', }; // ---------- PIN gate ---------- $('#pin-form').addEventListener('submit', async (e) => { e.preventDefault(); const pin = $('#pin-input').value; const { ok } = await api('/api/parent/verify', { method: 'POST', body: { pin } }); if (!ok) return ($('#pin-error').hidden = false); parentPin = pin; $('#pin-error').hidden = true; unlock(); }); async function unlock() { $('#pin-gate').hidden = true; $('#console').hidden = false; showPanel('kids'); loadGradingBadge(); } async function loadGradingBadge() { try { const { count } = await api('/api/grading-queue/count'); const pill = document.querySelector('.nav-pill[data-panel="grading"]'); if (count > 0) { pill.textContent = `β Grading (${count})`; pill.classList.add('grading-pending'); } else { pill.textContent = 'β Grading'; pill.classList.remove('grading-pending'); } } catch {} } if (parentPin) unlock(); // ---------- navigation ---------- document.querySelectorAll('.admin-nav .nav-pill').forEach((pill) => pill.addEventListener('click', () => showPanel(pill.dataset.panel)) ); document.querySelectorAll('[data-nav]').forEach((btn) => btn.addEventListener('click', () => showPanel(btn.dataset.nav)) ); function showPanel(name) { document.querySelectorAll('.panel').forEach((p) => (p.hidden = p.id !== `panel-${name}`)); document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.panel === name)); const loaders = { kids: loadKids, courses: loadCourses, planner: loadPlannerPanel, grading: loadGrading, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks, settings: loadOllamaConfig, }; if (loaders[name]) loaders[name](); if (name === 'course-detail') openCourseDetail(currentCourseId); } // ============================================================ // Kids // ============================================================ const THEME_COLORS = { blue: '#4f86f7', green: '#2e9e5b', purple: '#7c5cbf', orange: '#e8802a', pink: '#d45d8a', red: '#e84040', teal: '#20a8a0', yellow: '#c8960c', indigo: '#5b6abf' }; async function loadKids() { const students = await api('/api/students'); $('#student-rows').innerHTML = students.length ? students .map((s) => { const theme = s.theme || 'blue'; const swatches = Object.entries(THEME_COLORS) .map(([t, c]) => ``) .join(''); const streak = s.streak_count >= 2 ? `π₯ ${s.streak_count} day streak` : ''; return `
No kids yet β add one below.
`; document.querySelectorAll('[data-student-theme]').forEach((btn) => btn.addEventListener('click', async () => { const studentId = btn.dataset.studentTheme; const theme = btn.dataset.theme; await api(`/api/students/${studentId}`, { method: 'PUT', body: { theme } }); // Update selected state without full reload document.querySelectorAll(`[data-student-theme="${studentId}"]`).forEach((b) => b.classList.toggle('selected', b.dataset.theme === theme) ); }) ); document.querySelectorAll('[data-del-student]').forEach((btn) => btn.addEventListener('click', async () => { if (!confirm('Remove this kid and all their progress? This cannot be undone.')) return; await api(`/api/students/${btn.dataset.delStudent}`, { method: 'DELETE' }); msg('Kid removed.'); loadKids(); }) ); } $('#add-student-form').addEventListener('submit', async (e) => { e.preventDefault(); const name = $('#new-student-name').value.trim(); if (!name) return; await api('/api/students', { method: 'POST', body: { name, emoji: $('#new-student-emoji').value } }); $('#new-student-name').value = ''; msg('Kid added.'); loadKids(); }); // ============================================================ // Courses list + detail (units, items, roster) // ============================================================ let currentCourseId = null; let cachedLists = []; let cachedDecks = []; async function loadCourses() { const courses = await api('/api/admin/courses'); $('#course-rows').innerHTML = courses.length ? courses .map( (c) => `No courses yet β add one below.
`; document.querySelectorAll('[data-open-course]').forEach((btn) => btn.addEventListener('click', () => { currentCourseId = Number(btn.dataset.openCourse); showPanel('course-detail'); }) ); } $('#add-course-form').addEventListener('submit', async (e) => { e.preventDefault(); const name = $('#new-course-name').value.trim(); if (!name) return; const { id } = await api('/api/courses', { method: 'POST', body: { name, subject: $('#new-course-subject').value.trim(), color: $('#new-course-color').value }, }); $('#new-course-name').value = ''; $('#new-course-subject').value = ''; currentCourseId = id; 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'); // Preserve unit open/collapsed states and scroll position before re-rendering const existingBlocks = document.querySelectorAll('#cd-units .unit-block'); const savedOpen = Array.from(existingBlocks).map((b) => b.classList.contains('open')); const panelVisible = !document.getElementById('panel-course-detail').hidden; const savedScroll = panelVisible && existingBlocks.length ? window.scrollY : 0; const sameCoursePrev = existingBlocks.length > 0 && id === currentCourseId; const [course, roster] = await Promise.all([ api(`/api/admin/courses/${id}`), api(`/api/courses/${id}/roster`), ]); $('#cd-name').textContent = course.name; $('#cd-name-input').value = course.name; $('#cd-subject-input').value = course.subject || ''; $('#cd-color-input').value = course.color; $('#cd-archived').checked = !!course.archived; $('#cd-roster').innerHTML = roster .map( (s) => `` ) .join(''); document.querySelectorAll('[data-roster-student]').forEach((box) => box.addEventListener('change', async () => { if (box.checked) await api(`/api/courses/${id}/enroll`, { method: 'POST', body: { studentId: box.dataset.rosterStudent } }); else await api(`/api/courses/${id}/enroll/${box.dataset.rosterStudent}`, { method: 'DELETE' }); }) ); $('#cd-units').innerHTML = course.units .map( (u, uIdx) => `No units yet β add one below.
`; document.querySelectorAll('#cd-units .unit-toggle').forEach((hdr) => { hdr.addEventListener('click', (e) => { if (e.target.closest('button')) return; const block = hdr.closest('.unit-block'); block.classList.toggle('open'); }); }); document.querySelectorAll('[data-del-unit]').forEach((btn) => btn.addEventListener('click', async () => { if (!confirm('Delete this unit and everything in it?')) return; await api(`/api/units/${btn.dataset.delUnit}`, { method: 'DELETE' }); msg('Unit deleted.'); openCourseDetail(id); }) ); document.querySelectorAll('[data-add-item]').forEach((btn) => btn.addEventListener('click', () => openItemEditor(btn.dataset.addItem, null)) ); document.querySelectorAll('[data-edit-item]').forEach((btn) => btn.addEventListener('click', () => openItemEditor(btn.dataset.unitId, btn.dataset.editItem)) ); $('#cd-print').onclick = () => printCourse(id); // Restore which units were open and the scroll position if (sameCoursePrev && savedOpen.length) { document.querySelectorAll('#cd-units .unit-block').forEach((block, i) => { if (i < savedOpen.length) { block.classList.toggle('open', savedOpen[i]); const btn = block.querySelector('.unit-toggle'); if (btn) btn.setAttribute('aria-expanded', String(savedOpen[i])); } }); } if (savedScroll > 0) requestAnimationFrame(() => window.scrollTo(0, savedScroll)); document.querySelectorAll('.panel').forEach((p) => (p.hidden = p.id !== 'panel-course-detail')); document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.remove('active')); } $('#course-meta-form').addEventListener('submit', async (e) => { e.preventDefault(); await api(`/api/courses/${currentCourseId}`, { method: 'PUT', body: { name: $('#cd-name-input').value.trim(), subject: $('#cd-subject-input').value.trim(), color: $('#cd-color-input').value, archived: $('#cd-archived').checked, }, }); msg('Course saved.'); openCourseDetail(currentCourseId); }); $('#cd-duplicate').addEventListener('click', async () => { const { id } = await api(`/api/courses/${currentCourseId}/duplicate`, { method: 'POST' }); currentCourseId = id; showPanel('course-detail'); 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' }); msg('Course deleted.'); currentCourseId = null; showPanel('courses'); }); $('#add-unit-form').addEventListener('submit', async (e) => { e.preventDefault(); const name = $('#new-unit-name').value.trim(); if (!name) return; await api('/api/units', { method: 'POST', body: { courseId: currentCourseId, name } }); $('#new-unit-name').value = ''; msg('Unit added.'); openCourseDetail(currentCourseId); }); // ============================================================ // Item editor // ============================================================ let questionCount = 0; let cachedQuizTemplates = []; async function openItemEditor(unitId, itemId) { let courseDetail; [cachedLists, cachedDecks, cachedQuizTemplates, courseDetail] = await Promise.all([ api('/api/lists'), api('/api/decks'), api('/api/quiz-templates'), api(`/api/admin/courses/${currentCourseId}`), ]); $('#ie-list').innerHTML = cachedLists.map((l) => ``).join(''); $('#ie-deck').innerHTML = cachedDecks.map((d) => ``).join(''); // Populate template select $('#ie-template-select').innerHTML = `` + cachedQuizTemplates.map((t) => ``).join(''); // Populate prereq select (all items in course except this one) const allCourseItems = courseDetail.units.flatMap((u) => u.items); $('#ie-prereq').innerHTML = `` + allCourseItems .filter((it) => it.id !== Number(itemId)) .map((it) => ``) .join(''); $('#ie-id').value = itemId || ''; $('#ie-unit-id').value = unitId; $('#ie-error').hidden = true; $('#ie-questions').innerHTML = ''; questionCount = 0; $('#ie-pairs').innerHTML = ''; pairCount = 0; $('#ie-across-clues').innerHTML = ''; $('#ie-down-clues').innerHTML = ''; $('#ie-crossword-preview').innerHTML = ''; $('#ie-crossword-status').textContent = ''; currentCrosswordData = null; if (itemId) { const item = await api(`/api/admin/items/${itemId}`); $('#ie-heading').textContent = `Edit: ${item.title}`; $('#ie-type').value = item.type; $('#ie-title').value = item.title; $('#ie-body-lesson').innerHTML = item.body || ''; $('#ie-body-assignment').innerHTML = item.body || ''; $('#ie-points').value = item.points || 10; $('#ie-due-date').value = item.due_date || ''; $('#ie-allow-retakes').checked = !!item.allow_retakes; $('#ie-prereq').value = item.prereq_item_id || ''; $('#ie-evidence-mode').value = item.evidence_mode || 'none'; $('#ie-retake-policy').value = item.retake_policy || 'latest'; if (item.type === 'spelling_practice' || item.type === 'spelling_test') $('#ie-list').value = item.ref_id; if (item.type === 'flashcards') $('#ie-deck').value = item.ref_id; if (item.type === 'quiz') { $('#ie-quiz-instructions').value = item.body || ''; (item.questions || []).forEach(addQuestionRow); } if (item.type === 'matching') { $('#ie-matching-instructions').value = item.body || ''; $('#ie-pairs').innerHTML = ''; pairCount = 0; (item.questions || []).forEach(addMatchingRow); } if (item.type === 'crossword' && item.crosswordData) { currentCrosswordData = item.crosswordData; (item.crosswordData.across || []).forEach(w => addCrosswordClueRow('across', { word: w.answer, clue: w.clue })); (item.crosswordData.down || []).forEach(w => addCrosswordClueRow('down', { word: w.answer, clue: w.clue })); renderCrosswordPreview(item.crosswordData); $('#ie-crossword-status').textContent = `${item.crosswordData.placed || ''} words placed`; } $('#ie-delete').hidden = false; $('#ie-delete').onclick = async () => { if (!confirm('Delete this item and any grades for it?')) return; await api(`/api/items/${itemId}`, { method: 'DELETE' }); msg('Item deleted.'); showPanel('course-detail'); }; } else { $('#ie-heading').textContent = 'New item'; $('#ie-type').value = 'lesson'; $('#ie-title').value = ''; $('#ie-body-lesson').innerHTML = ''; $('#ie-body-assignment').innerHTML = ''; $('#ie-quiz-instructions').value = ''; $('#ie-matching-instructions').value = ''; currentCrosswordData = null; $('#ie-points').value = 10; $('#ie-due-date').value = ''; $('#ie-allow-retakes').checked = false; $('#ie-prereq').value = ''; $('#ie-evidence-mode').value = 'none'; $('#ie-retake-policy').value = 'latest'; $('#ie-delete').hidden = true; } updateItemFieldVisibility(); resetScanSection(); resetImportSection(); showPanel('item-editor'); } function updateItemFieldVisibility() { const type = $('#ie-type').value; $('#ie-field-lesson').hidden = type !== 'lesson'; $('#ie-field-assignment').hidden = type !== 'assignment'; $('#ie-field-quiz').hidden = type !== 'quiz'; $('#ie-field-matching').hidden = type !== 'matching'; $('#ie-field-crossword').hidden = type !== 'crossword'; $('#ie-field-spelling').hidden = type !== 'spelling_practice' && type !== 'spelling_test'; $('#ie-field-flashcards').hidden = type !== 'flashcards'; $('#ie-scan-section').hidden = type !== 'lesson' && type !== 'quiz'; $('#ie-import-section').hidden = type === 'spelling_practice' || type === 'spelling_test' || type === 'flashcards'; } $('#ie-type').addEventListener('change', updateItemFieldVisibility); // ---------- rich text editor (lesson body) ---------- function getRteBody(el) { const html = el.innerHTML.trim(); return html === 'Add a kid first.
`; return; } $('#planner-student').innerHTML = students.map((s) => ``).join(''); if (!plannerStudentId || !students.some((s) => s.id === plannerStudentId)) plannerStudentId = students[0].id; $('#planner-student').value = plannerStudentId; $('#auto-course').innerHTML = courses.map((c) => ``).join(''); if (!$('#auto-start-date').value) $('#auto-start-date').value = plannerWeekStart; await renderPlanner(); } $('#auto-schedule-btn').addEventListener('click', async () => { const courseId = Number($('#auto-course').value); const startDate = $('#auto-start-date').value; const itemsPerDay = Number($('#auto-items-per-day').value) || 1; if (!courseId || !startDate) { $('#auto-schedule-msg').textContent = 'Pick a course and start date.'; return; } const { scheduled } = await api('/api/schedule/auto', { method: 'POST', body: { studentId: plannerStudentId, courseId, startDate, itemsPerDay }, }); $('#auto-schedule-msg').textContent = `Scheduled ${scheduled} item${scheduled === 1 ? '' : 's'} starting ${startDate}.`; plannerWeekStart = mondayOf(startDate + 'T12:00:00'); renderPlanner(); }); $('#planner-student').addEventListener('change', () => { plannerQueue = []; updatePlannerSaveBtn(); plannerStudentId = Number($('#planner-student').value); renderPlanner(); }); $('#planner-view-week').addEventListener('click', () => { plannerMode = 'week'; $('#planner-view-week').classList.add('active-view'); $('#planner-view-month').classList.remove('active-view'); $('#planner-copy').hidden = false; renderPlanner(); }); $('#planner-view-month').addEventListener('click', () => { plannerMode = 'month'; // derive the month from the current week view date const ref = new Date(plannerWeekStart + 'T00:00:00'); plannerMonthRef = `${ref.getFullYear()}-${String(ref.getMonth() + 1).padStart(2, '0')}-01`; plannerWeekStart = firstMondayOfMonth(plannerMonthRef); $('#planner-view-month').classList.add('active-view'); $('#planner-view-week').classList.remove('active-view'); $('#planner-copy').hidden = true; renderPlanner(); }); $('#planner-prev').addEventListener('click', () => { if (plannerMode === 'month') { plannerMonthRef = addMonths(plannerMonthRef, -1); plannerWeekStart = firstMondayOfMonth(plannerMonthRef); } else { plannerWeekStart = addDays(plannerWeekStart, -7); } renderPlanner(); }); $('#planner-next').addEventListener('click', () => { if (plannerMode === 'month') { plannerMonthRef = addMonths(plannerMonthRef, 1); plannerWeekStart = firstMondayOfMonth(plannerMonthRef); } else { plannerWeekStart = addDays(plannerWeekStart, 7); } renderPlanner(); }); $('#planner-copy').addEventListener('click', async () => { const from = addDays(plannerWeekStart, -7); const { copied } = await api('/api/schedule/copy', { method: 'POST', body: { studentId: plannerStudentId, from, to: plannerWeekStart } }); msg(`Copied ${copied} task${copied === 1 ? '' : 's'} from last week.`); renderPlanner(); }); $('#planner-print').addEventListener('click', () => { if (plannerMode === 'month') printMonthReport(plannerStudentId, plannerWeekStart, plannerMonthRef); else printWeekReport(plannerStudentId, plannerWeekStart); }); const DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; function attachPlannerEvents() { let draggingId = null; document.querySelectorAll('.planner-task[draggable]').forEach((el) => { el.addEventListener('dragstart', (e) => { draggingId = el.dataset.taskId; el.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; }); el.addEventListener('dragend', () => { draggingId = null; el.classList.remove('dragging'); document.querySelectorAll('.planner-tasks').forEach((col) => col.classList.remove('drop-target')); }); }); document.querySelectorAll('.planner-tasks').forEach((col) => { col.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; col.classList.add('drop-target'); }); col.addEventListener('dragleave', () => col.classList.remove('drop-target')); col.addEventListener('drop', async (e) => { e.preventDefault(); col.classList.remove('drop-target'); if (!draggingId) return; await api(`/api/schedule/${draggingId}`, { method: 'PATCH', body: { date: col.dataset.dropDate } }); renderPlanner(); }); }); document.querySelectorAll('[data-del-schedule]').forEach((btn) => btn.addEventListener('click', async () => { await api(`/api/schedule/${btn.dataset.delSchedule}`, { method: 'DELETE' }); renderPlanner(); }) ); document.querySelectorAll('[data-add-task]').forEach((btn) => btn.addEventListener('click', () => toggleAddTaskForm(btn.dataset.addTask)) ); } function renderDayCell(day, tasks, compact = false, spellingTests = []) { const dayTasks = tasks.filter((t) => t.date === day.date); const dayTests = spellingTests.filter((t) => t.date === day.date); const testBadges = dayTests.map((t) => { const pct = Math.round((t.score / t.total) * 100); return `β
` : '')}${esc(p.trim())}
`).join('')}Nothing scheduled this week.
'; const gradedRows = r.graded .map((g) => `Week of ${r.start}
| Item | Course | Score |
|---|
Nothing graded this week.
'}| List | Score |
|---|
No spelling tests this week.
'}