// ---------- 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 `
${esc(s.emoji)}
${esc(s.name)}
${streak}
${swatches}
Remove
`;
})
.join('')
: `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) => `
${esc(c.name)}
${esc(c.subject || '')}
${c.unitCount} unit${c.unitCount === 1 ? '' : 's'}, ${c.itemCount} item${c.itemCount === 1 ? '' : 's'}
${c.archived ? 'archived ' : ''}
Open
`
)
.join('')
: `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');
});
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) => `
${esc(s.emoji)} ${esc(s.name)}
`
)
.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) => `
Unit ${uIdx + 1}
${esc(u.name)}
${u.items.length} item${u.items.length !== 1 ? 's' : ''}
βΎ
Delete unit
${u.items
.map(
(it) => `
${TYPE_ICON[it.type]}
${esc(it.title)}
${TYPE_LABEL[it.type]}
Edit
`
)
.join('')}
+ Add item to this unit
`
)
.join('') || `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-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) => `${esc(l.name)} `).join('');
$('#ie-deck').innerHTML = cachedDecks.map((d) => `${esc(d.name)} `).join('');
// Populate template select
$('#ie-template-select').innerHTML = `β load from template β ` +
cachedQuizTemplates.map((t) => `${esc(t.name)} (${t.questionCount}q) `).join('');
// Populate prereq select (all items in course except this one)
const allCourseItems = courseDetail.units.flatMap((u) => u.items);
$('#ie-prereq').innerHTML = `None ` +
allCourseItems
.filter((it) => it.id !== Number(itemId))
.map((it) => `${esc(it.title)} `)
.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 === ' ' ? '' : html;
}
function getLessonBody() { return getRteBody($('#ie-body-lesson')); }
function getAssignmentBody() { return getRteBody($('#ie-body-assignment')); }
// Format buttons β mousedown+preventDefault keeps focus/selection in the editor
document.querySelectorAll('.rte-btn[data-cmd]').forEach((btn) => {
btn.addEventListener('mousedown', (e) => {
e.preventDefault();
const editor = btn.closest('.rte-group').querySelector('.rte-editor');
editor.focus();
document.execCommand(btn.dataset.cmd, false, btn.dataset.val || null);
});
});
// Link buttons
document.querySelectorAll('.rte-link-btn').forEach((btn) => {
btn.addEventListener('mousedown', (e) => {
e.preventDefault();
const editor = btn.closest('.rte-group').querySelector('.rte-editor');
editor.focus();
const sel = window.getSelection();
const savedRange = sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null;
const url = prompt('Enter URL (e.g. https://example.com):');
if (url) {
editor.focus();
if (savedRange) { sel.removeAllRanges(); sel.addRange(savedRange); }
document.execCommand('createLink', false, url);
}
});
});
// YouTube embed buttons
document.querySelectorAll('.rte-youtube-btn').forEach((btn) => {
btn.addEventListener('mousedown', (e) => {
e.preventDefault();
const editor = btn.closest('.rte-group').querySelector('.rte-editor');
editor.focus();
const sel = window.getSelection();
const savedRange = sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null;
const input = prompt('YouTube URL or video ID:');
if (!input) return;
const match = input.match(/(?:youtu\.be\/|youtube\.com\/(?:embed\/|watch\?v=))([a-zA-Z0-9_-]{11})/);
const id = match ? match[1] : input.trim();
if (id) {
editor.focus();
if (savedRange) { sel.removeAllRanges(); sel.addRange(savedRange); }
document.execCommand('insertHTML', false,
`VIDEO `
);
}
});
});
// ---------- scan a page (photo -> lesson/quiz content via local Ollama) ----------
let scanImageBase64 = null;
function resetImportSection() {
$('#import-local-input').value = '';
$('#import-local-name').textContent = '';
$('#import-go-btn').disabled = true;
$('#import-status').textContent = '';
}
function resetScanSection() {
scanImageBase64 = null;
$('#scan-file-input').value = '';
$('#scan-preview').hidden = true;
$('#scan-preview').src = '';
$('#scan-go-btn').disabled = true;
$('#scan-status').textContent = '';
}
function fileToScanImage(file, maxDim = 1600, quality = 0.85) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Could not read that file'));
reader.onload = () => {
const img = new Image();
img.onerror = () => reject(new Error('Could not read that image'));
img.onload = () => {
let { width, height } = img;
if (width > maxDim || height > maxDim) {
const scale = maxDim / Math.max(width, height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.src = reader.result;
};
reader.readAsDataURL(file);
});
}
$('#scan-pick-btn').addEventListener('click', () => $('#scan-file-input').click());
$('#scan-file-input').addEventListener('change', async () => {
const file = $('#scan-file-input').files[0];
if (!file) return;
try {
const dataUrl = await fileToScanImage(file);
scanImageBase64 = dataUrl.split(',')[1];
$('#scan-preview').src = dataUrl;
$('#scan-preview').hidden = false;
$('#scan-go-btn').disabled = false;
$('#scan-status').textContent = '';
} catch (err) {
$('#scan-status').textContent = `β ${err.message}`;
}
});
$('#scan-go-btn').addEventListener('click', async () => {
if (!scanImageBase64) return;
const mode = $('#ie-type').value === 'quiz' ? 'quiz' : 'lesson';
$('#scan-go-btn').disabled = true;
$('#scan-status').textContent = 'π Reading the pageβ¦ this can take a minute or two on local hardware.';
try {
const result = await api('/api/admin/scan', { method: 'POST', body: { image: scanImageBase64, mode } });
if (result.title) $('#ie-title').value = result.title;
const providerLabel = result._provider === 'cloud' ? 'cloud API' : 'local Ollama';
const fallbackNote = result._fallback ? ` (primary failed β used ${providerLabel} as fallback: ${result._primaryError})` : ` via ${providerLabel}`;
if (mode === 'lesson') {
$('#ie-body-lesson').innerHTML = result.body || '';
$('#scan-status').textContent = `β
Scanned${fallbackNote}. Review the text below before saving.`;
} else {
$('#ie-questions').innerHTML = '';
questionCount = 0;
// addQuestionRow expects correct_answer (snake_case); scan returns correctAnswer (camelCase) β normalize here.
(result.questions || []).forEach((q) => addQuestionRow({ ...q, correct_answer: q.correctAnswer }));
const blanks = (result.questions || []).filter((q) => !q.correctAnswer).length;
$('#scan-status').textContent = `β
Found ${result.questions?.length || 0} question(s)${fallbackNote}` +
(blanks ? ` β ${blanks} need${blanks === 1 ? 's' : ''} a correct answer` : '') + '. Review before saving.';
}
} catch (err) {
$('#scan-status').textContent = `β ${err.message}`;
} finally {
$('#scan-go-btn').disabled = false;
}
});
// ---------- import from local .docx file ----------
$('#import-browse-btn').addEventListener('click', () => $('#import-local-input').click());
$('#import-local-input').addEventListener('change', () => {
const file = $('#import-local-input').files[0];
$('#import-local-name').textContent = file ? file.name : '';
$('#import-go-btn').disabled = !file;
$('#import-status').textContent = '';
});
$('#import-go-btn').addEventListener('click', async () => {
const file = $('#import-local-input').files[0];
if (!file) return;
const type = $('#ie-type').value;
$('#import-go-btn').disabled = true;
$('#import-status').textContent = 'π€ Uploadingβ¦';
try {
const arrayBuffer = await file.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
let binary = '';
for (let i = 0; i < bytes.length; i += 8192) {
binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + 8192, bytes.length)));
}
const base64 = btoa(binary);
const result = await api('/api/admin/upload-attachment', {
method: 'POST',
body: { base64, originalName: file.name },
});
const downloadLink = `β¬οΈ ${result.originalName}
`;
const content = result.htmlBody
? downloadLink + result.htmlBody
: `π ${result.originalName}
`;
const editor = type === 'assignment' ? $('#ie-body-assignment') : $('#ie-body-lesson');
editor.innerHTML += content;
$('#import-status').textContent = 'β
Imported β content is now editable above.';
} catch (err) {
$('#import-status').textContent = `β ${err.message}`;
} finally {
$('#import-go-btn').disabled = false;
}
});
function addQuestionRow(q = {}) {
const n = questionCount++;
const div = document.createElement('div');
div.className = 'quiz-q-row';
div.dataset.qn = n;
div.innerHTML = `
Multiple choice
True / False
Short answer
β `;
div.querySelector('.qn-remove').addEventListener('click', () => div.remove());
$('#ie-questions').appendChild(div);
}
$('#ie-add-question').addEventListener('click', () => addQuestionRow());
let pairCount = 0;
function addMatchingRow(pair = {}) {
const n = pairCount++;
const div = document.createElement('div');
div.className = 'matching-pair-row';
div.innerHTML = `
β `;
div.querySelector('.pair-remove').addEventListener('click', () => div.remove());
$('#ie-pairs').appendChild(div);
}
$('#ie-add-pair').addEventListener('click', () => addMatchingRow());
// ---------- crossword editor ----------
let currentCrosswordData = null;
function addCrosswordClueRow(dir, entry = {}) {
const container = dir === 'across' ? $('#ie-across-clues') : $('#ie-down-clues');
const div = document.createElement('div');
div.className = 'cw-clue-row';
div.innerHTML = `
β `;
div.querySelector('.cw-word').addEventListener('input', (e) => { e.target.value = e.target.value.toUpperCase(); });
div.querySelector('.cw-remove').addEventListener('click', () => div.remove());
container.appendChild(div);
}
$('#ie-add-across').addEventListener('click', () => addCrosswordClueRow('across'));
$('#ie-add-down').addEventListener('click', () => addCrosswordClueRow('down'));
function renderCrosswordPreview(cw) {
const cell = 22;
const w = cw.cols * cell, h = cw.rows * cell;
let svg = ``;
for (let r = 0; r < cw.rows; r++) {
for (let c = 0; c < cw.cols; c++) {
const x = c * cell, y = r * cell;
if (cw.grid[r][c] === null) {
svg += ` `;
} else {
svg += ` `;
if (cw.grid[r][c]) {
svg += `${esc(cw.grid[r][c])} `;
}
}
}
}
// Draw numbers
const allWords = [...(cw.across||[]), ...(cw.down||[])];
const nums = {};
for (const w2 of allWords) if (w2.num) nums[`${w2.row},${w2.col}`] = w2.num;
for (const [key, n] of Object.entries(nums)) {
const [r, c] = key.split(',').map(Number);
svg += `${n} `;
}
svg += ' ';
$('#ie-crossword-preview').innerHTML = svg;
}
$('#ie-crossword-generate').addEventListener('click', async () => {
const across = Array.from($('#ie-across-clues').querySelectorAll('.cw-clue-row')).map(row => ({
word: row.querySelector('.cw-word').value.trim(),
clue: row.querySelector('.cw-clue').value.trim(),
})).filter(e => e.word && e.clue);
const down = Array.from($('#ie-down-clues').querySelectorAll('.cw-clue-row')).map(row => ({
word: row.querySelector('.cw-word').value.trim(),
clue: row.querySelector('.cw-clue').value.trim(),
})).filter(e => e.word && e.clue);
if (across.length + down.length < 2) return msg('Add at least 2 words with clues before generating.');
$('#ie-crossword-status').textContent = 'β‘ Generatingβ¦';
try {
const result = await api('/api/crossword/generate', { method: 'POST', body: { across, down } });
currentCrosswordData = result;
renderCrosswordPreview(result);
const skipped = result.total - result.placed;
$('#ie-crossword-status').textContent = `β
${result.placed} words placed${skipped ? ` (${skipped} couldn't fit β try sharing more letters)` : ''}`;
} catch (err) {
$('#ie-crossword-status').textContent = `β ${err.message}`;
}
});
$('#ie-load-template').addEventListener('click', async () => {
const tid = $('#ie-template-select').value;
if (!tid) return msg('Pick a template first.');
const t = await api(`/api/quiz-templates/${tid}`);
$('#ie-questions').innerHTML = '';
questionCount = 0;
t.questions.forEach((q) => addQuestionRow({ ...q, correct_answer: q.correct_answer }));
msg(`Loaded "${t.name}" β ${t.questions.length} question(s).`);
});
$('#ie-save-template').addEventListener('click', async () => {
const itemId = $('#ie-id').value;
if (!itemId) return msg('Save the item first, then save as template.');
const name = prompt('Template name:');
if (!name) return;
try {
await api('/api/quiz-templates', { method: 'POST', body: { name, itemId: Number(itemId) } });
msg(`Template "${name}" saved.`);
cachedQuizTemplates = await api('/api/quiz-templates');
$('#ie-template-select').innerHTML = `β load from template β ` +
cachedQuizTemplates.map((t) => `${esc(t.name)} (${t.questionCount}q) `).join('');
} catch (err) { msg(err.message); }
});
$('#item-form').addEventListener('submit', async (e) => {
e.preventDefault();
const type = $('#ie-type').value;
const title = $('#ie-title').value.trim();
const prereqVal = $('#ie-prereq').value;
const body = {
unitId: Number($('#ie-unit-id').value),
type,
title,
body: type === 'lesson' ? getLessonBody()
: type === 'assignment' ? getAssignmentBody()
: type === 'quiz' ? $('#ie-quiz-instructions').value.trim()
: type === 'matching' ? $('#ie-matching-instructions').value.trim()
: '',
points: type === 'assignment' ? Number($('#ie-points').value) || 0 : 0,
refId: type === 'spelling_practice' || type === 'spelling_test' ? Number($('#ie-list').value)
: type === 'flashcards' ? Number($('#ie-deck').value) : null,
dueDate: $('#ie-due-date').value || null,
allowRetakes: $('#ie-allow-retakes').checked,
prereqItemId: prereqVal ? Number(prereqVal) : null,
evidenceMode: $('#ie-evidence-mode').value,
retakePolicy: $('#ie-retake-policy').value,
};
if (type === 'quiz') {
body.questions = Array.from(document.querySelectorAll('.quiz-q-row')).map((row) => ({
type: row.querySelector('.qn-type').value,
prompt: row.querySelector('.qn-prompt').value.trim(),
choices: row.querySelector('.qn-choices').value.split(',').map((c) => c.trim()).filter(Boolean),
correctAnswer: row.querySelector('.qn-correct').value.trim(),
points: Number(row.querySelector('.qn-points').value) || 1,
}));
}
if (type === 'matching') {
body.questions = Array.from(document.querySelectorAll('.matching-pair-row')).map((row) => ({
type: 'match',
prompt: row.querySelector('.pair-clue').value.trim(),
correctAnswer: row.querySelector('.pair-word').value.trim(),
choices: [],
points: 1,
})).filter((q) => q.prompt && q.correctAnswer);
}
if (type === 'crossword') {
if (!currentCrosswordData) {
$('#ie-error').textContent = 'Generate the crossword first.';
$('#ie-error').hidden = false;
return;
}
body.crosswordData = currentCrosswordData;
}
const id = $('#ie-id').value;
try {
if (id) await api(`/api/items/${id}`, { method: 'PUT', body });
else await api('/api/items', { method: 'POST', body });
msg('Item saved.');
showPanel('course-detail');
} catch (err) {
$('#ie-error').textContent = err.message;
$('#ie-error').hidden = false;
}
});
// ============================================================
// Planner
// ============================================================
function mondayOf(d) {
const date = new Date(d);
const day = date.getDay();
const diff = day === 0 ? -6 : 1 - day;
date.setDate(date.getDate() + diff);
return date.toISOString().slice(0, 10);
}
function addDays(dateStr, n) {
const d = new Date(dateStr + 'T00:00:00');
d.setDate(d.getDate() + n);
return d.toISOString().slice(0, 10);
}
let plannerWeekStart = mondayOf(new Date());
let plannerQueue = [];
let plannerStudentId = null;
let plannerCoursesCache = [];
let plannerMode = 'week'; // 'week' | 'month'
let plannerMonthRef = null; // YYYY-MM-01 of the month being viewed
function firstMondayOfMonth(dateStr) {
const d = new Date(dateStr + 'T00:00:00');
d.setDate(1);
return mondayOf(d);
}
function addMonths(dateStr, n) {
const d = new Date(dateStr + 'T00:00:00');
d.setMonth(d.getMonth() + n);
return d.toISOString().slice(0, 10);
}
function monthLabel(dateStr) {
return new Date(dateStr + 'T00:00:00').toLocaleString('default', { month: 'long', year: 'numeric' });
}
async function loadPlannerPanel() {
const [students, courses] = await Promise.all([api('/api/students'), api('/api/admin/courses')]);
if (students.length === 0) {
$('#planner-grid').innerHTML = `Add a kid first.
`;
return;
}
$('#planner-student').innerHTML = students.map((s) => `${esc(s.emoji)} ${esc(s.name)} `).join('');
if (!plannerStudentId || !students.some((s) => s.id === plannerStudentId)) plannerStudentId = students[0].id;
$('#planner-student').value = plannerStudentId;
$('#auto-course').innerHTML = courses.map((c) => `${esc(c.name)} `).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 `β Spelling ${t.score}/${t.total} (${pct}%)
`;
}).join('');
return `
${compact ? day.name.slice(0,3) : day.name}${compact ? day.date.slice(5) : day.date}
${testBadges}
${dayTasks.map((t) => `
${t.itemId ? TYPE_ICON[t.type] : 'π'} ${esc(t.itemTitle || t.offlineTitle)}
β
`).join('') || (!testBadges ? `
β
` : '')}
+ Add
`;
}
async function renderPlanner() {
const courses = await api(`/api/courses/mine/${plannerStudentId}`);
plannerCoursesCache = courses;
if (plannerMode === 'month') {
// Build 4-5 week rows covering the month
const month = new Date(plannerMonthRef + 'T00:00:00').getMonth();
const weeks = [];
let cursor = plannerWeekStart;
for (let w = 0; w < 6; w++) {
const weekDays = DAY_NAMES.map((name, i) => ({ name, date: addDays(cursor, i) }));
const fridayMonth = new Date(weekDays[4].date + 'T00:00:00').getMonth();
const mondayMonth = new Date(weekDays[0].date + 'T00:00:00').getMonth();
if (w > 0 && mondayMonth !== month && fridayMonth !== month) break;
weeks.push(weekDays);
cursor = addDays(cursor, 7);
}
const rangeStart = weeks[0][0].date;
const rangeEnd = weeks[weeks.length - 1][4].date;
const [{ tasks }, { tests: spellingTests }] = await Promise.all([
api(`/api/schedule-range/${plannerStudentId}?start=${rangeStart}&end=${rangeEnd}`),
api(`/api/admin/students/${plannerStudentId}/tests-range?from=${rangeStart}&to=${rangeEnd}`),
]);
$('#planner-week-label').textContent = monthLabel(plannerMonthRef);
$('#planner-grid').className = 'planner-grid planner-month';
$('#planner-grid').innerHTML =
`` +
weeks.map((week) =>
`${week.map((day) => renderDayCell(day, tasks, true, spellingTests)).join('')}
`
).join('');
} else {
const end = addDays(plannerWeekStart, 4);
$('#planner-week-label').textContent = `${plannerWeekStart} β ${end}`;
const [{ tasks }, { tests: spellingTests }] = await Promise.all([
api(`/api/schedule-week/${plannerStudentId}?start=${plannerWeekStart}`),
api(`/api/admin/students/${plannerStudentId}/tests-range?from=${plannerWeekStart}&to=${end}`),
]);
const days = DAY_NAMES.map((name, i) => ({ name, date: addDays(plannerWeekStart, i) }));
$('#planner-grid').className = 'planner-grid';
$('#planner-grid').innerHTML = days.map((day) => renderDayCell(day, tasks, false, spellingTests)).join('');
}
attachPlannerEvents();
}
function toggleAddTaskForm(date) {
const box = document.querySelector(`[data-form-for="${date}"]`);
if (!box.hidden) { box.hidden = true; return; }
const itemOptions = plannerCoursesCache
.map((c) => ` `)
.join('');
box.hidden = false;
box.innerHTML = `
β pick a course item β
or
Add `;
const courseSelect = box.querySelector('.task-course-select');
(async () => {
for (const c of plannerCoursesCache) {
const detail = await api(`/api/admin/courses/${c.id}`);
const group = document.createElement('optgroup');
group.label = c.name;
for (const u of detail.units) {
for (const it of u.items) {
const opt = document.createElement('option');
opt.value = it.id;
opt.textContent = `${TYPE_ICON[it.type]} ${it.title}`;
group.appendChild(opt);
}
}
courseSelect.appendChild(group);
}
})();
box.querySelector('.task-save-btn').addEventListener('click', () => {
const itemId = courseSelect.value;
const title = box.querySelector('.task-offline-input').value.trim();
if (!itemId && !title) return;
const label = itemId
? courseSelect.options[courseSelect.selectedIndex].textContent
: `π ${title}`;
plannerQueue.push({ date, itemId: itemId || null, title, label });
updatePlannerSaveBtn();
// Show as pending in the day cell without a full re-render
const tasksEl = document.querySelector(`.planner-tasks[data-drop-date="${date}"]`);
if (tasksEl) {
const hint = tasksEl.querySelector('.hint.tiny');
if (hint) hint.remove();
const el = document.createElement('div');
el.className = 'planner-task planner-pending';
el.textContent = label;
tasksEl.appendChild(el);
}
courseSelect.value = '';
box.querySelector('.task-offline-input').value = '';
});
}
function updatePlannerSaveBtn() {
const btn = $('#planner-save-all');
const n = plannerQueue.length;
btn.hidden = n === 0;
btn.textContent = `πΎ Save ${n} item${n === 1 ? '' : 's'}`;
}
$('#planner-save-all').addEventListener('click', async () => {
const count = plannerQueue.length;
if (!count) return;
for (const item of plannerQueue) {
await api('/api/schedule', {
method: 'POST',
body: { studentId: plannerStudentId, date: item.date, itemId: item.itemId, title: item.title },
});
}
plannerQueue = [];
updatePlannerSaveBtn();
msg(`${count} item${count === 1 ? '' : 's'} added to planner.`);
renderPlanner();
});
function renderPrintTaskBlock(t) {
const title = t.itemTitle || t.offlineTitle;
const course = t.courseName ? `${esc(t.courseName)} ` : `Offline `;
const status = t.done ? `β done ` : '';
const typeTag = t.type ? `${TYPE_LABEL[t.type] || t.type} ` : '';
let body = '';
if (t.body) body = `${t.body.split(/\n\n+/).map((p) => `
${esc(p.trim())}
`).join('')}
`;
return `
${body}
`;
}
async function printWeekReport(studentId, start) {
const r = await api(`/api/week-report/${studentId}?start=${start}`);
// Group tasks by date
const byDate = {};
for (const t of r.tasks) { (byDate[t.date] = byDate[t.date] || []).push(t); }
const dayBlocks = Object.entries(byDate).map(([date, tasks]) =>
`
${date}
${tasks.map(renderPrintTaskBlock).join('')}
`
).join('') || 'Nothing scheduled this week.
';
const gradedRows = r.graded
.map((g) => `${esc(g.itemTitle)} ${esc(g.courseName)} ${g.score}/${g.points_possible} `)
.join('');
const spellingRows = r.spellingTests
.map((t) => `${esc(t.list)} ${t.score}/${t.total} `)
.join('');
const win = window.open('', '_blank');
win.document.write(`Weekly Plan β ${esc(r.student)}
Weekly Plan β ${esc(r.student)}
Week of ${r.start}
Schedule
${dayBlocks}
Graded work
${gradedRows ? `Item Course Score ${gradedRows}
` : 'Nothing graded this week.
'}
Spelling tests
${spellingRows ? `` : 'No spelling tests this week.
'}