Add 4 UX improvements to spelling/flashcards panel
- Assign → button on each list row opens a popup to pick a kid directly without scrolling up to the assignment dropdowns - Hide/Unhide button on built-in lists (stored in localStorage) keeps UNGROUPED clean; Show hidden toggle reveals them dimmed when needed - Mastery progress bar on each kid showing X/Y words mastered for their current assigned list (server now returns listProgress in /api/overview) - Filter-lists / filter-decks search input for fast lookup by name Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
5c2b5baec2
commit
774979f2e4
4 files changed
+152
-21
No files matched your search
@@ -300,6 +300,10 @@
|
||||
<h2>This week's list per kid</h2>
|
||||
<div id="assign-rows"></div>
|
||||
<h2>Word lists</h2>
|
||||
<div class="filter-bar">
|
||||
<input id="list-filter" type="search" placeholder="Filter lists…" aria-label="Filter lists">
|
||||
<button type="button" id="list-show-hidden" class="secondary small" hidden>Show hidden</button>
|
||||
</div>
|
||||
<div id="list-rows"></div>
|
||||
<h2>Test results</h2>
|
||||
<div id="results-area"></div>
|
||||
@@ -347,6 +351,9 @@
|
||||
<h1>Flashcard Decks</h1>
|
||||
<div class="panel-cols">
|
||||
<div class="panel-col">
|
||||
<div class="filter-bar">
|
||||
<input id="deck-filter" type="search" placeholder="Filter decks…" aria-label="Filter decks">
|
||||
</div>
|
||||
<div id="deck-rows"></div>
|
||||
</div>
|
||||
<div class="panel-col">
|
||||
@@ -437,6 +444,12 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Floating assign popup -->
|
||||
<div id="assign-popup" hidden>
|
||||
<p class="assign-popup-label">Assign to…</p>
|
||||
<div id="assign-popup-students"></div>
|
||||
</div>
|
||||
|
||||
<script src="admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+98
-21
@@ -1274,9 +1274,11 @@ async function renderGradebook(courseId) {
|
||||
// Spelling (word lists + weekly assignment + results)
|
||||
// ============================================================
|
||||
|
||||
let cachedStudents = [];
|
||||
async function loadSpelling() {
|
||||
const [overview, lists] = await Promise.all([api('/api/overview'), api('/api/lists')]);
|
||||
cachedLists = lists;
|
||||
cachedStudents = overview.students;
|
||||
renderAssignRows(overview.students);
|
||||
renderLists(lists);
|
||||
renderResults(overview.students);
|
||||
@@ -1287,16 +1289,20 @@ function renderAssignRows(students) {
|
||||
`<option value="">— no list —</option>` +
|
||||
cachedLists.map((l) => `<option value="${l.id}" ${sel === l.id ? 'selected' : ''}>${esc(l.name)}</option>`).join('');
|
||||
$('#assign-rows').innerHTML = students.length
|
||||
? students
|
||||
.map(
|
||||
(s) => `<div class="item-row">
|
||||
<span>${esc(s.emoji)}</span>
|
||||
<strong class="grow">${esc(s.name)}</strong>
|
||||
<span>🌟 ${s.mastered} mastered</span>
|
||||
<label>This week: <select data-assign="${s.id}">${options(s.assignment?.id)}</select></label>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
? students.map((s) => {
|
||||
const lp = s.listProgress;
|
||||
const progressHtml = lp
|
||||
? `<span class="mastery-stat">${lp.mastered}/${lp.total} mastered
|
||||
<span class="mastery-bar"><span class="mastery-fill" style="width:${lp.total ? Math.round((lp.mastered/lp.total)*100) : 0}%"></span></span>
|
||||
</span>`
|
||||
: `<span class="mastery-stat">No list assigned</span>`;
|
||||
return `<div class="item-row">
|
||||
<span>${esc(s.emoji)}</span>
|
||||
<strong class="grow">${esc(s.name)}</strong>
|
||||
${progressHtml}
|
||||
<label>This week: <select data-assign="${s.id}">${options(s.assignment?.id)}</select></label>
|
||||
</div>`;
|
||||
}).join('')
|
||||
: `<p class="hint">No kids yet.</p>`;
|
||||
|
||||
document.querySelectorAll('[data-assign]').forEach((sel) =>
|
||||
@@ -1334,28 +1340,51 @@ function renderGroupSections(groups, renderItem) {
|
||||
return html || '<p class="hint">None yet.</p>';
|
||||
}
|
||||
|
||||
function renderLists(lists) {
|
||||
const groups = groupByName(lists);
|
||||
const hiddenLists = new Set(JSON.parse(localStorage.getItem('hiddenLists') || '[]'));
|
||||
function saveHiddenLists() { localStorage.setItem('hiddenLists', JSON.stringify([...hiddenLists])); }
|
||||
|
||||
function renderLists(lists, filterText = '') {
|
||||
const q = filterText.toLowerCase();
|
||||
const visible = lists.filter((l) => !hiddenLists.has(l.id) && (!q || l.name.toLowerCase().includes(q)));
|
||||
const hidden = lists.filter((l) => hiddenLists.has(l.id) && (!q || l.name.toLowerCase().includes(q)));
|
||||
|
||||
const showHiddenBtn = $('#list-show-hidden');
|
||||
showHiddenBtn.hidden = hidden.length === 0;
|
||||
showHiddenBtn.textContent = showHiddenBtn.dataset.showing === '1'
|
||||
? `Hide hidden (${hidden.length})`
|
||||
: `Show hidden (${hidden.length})`;
|
||||
|
||||
const displayList = showHiddenBtn.dataset.showing === '1' ? [...visible, ...hidden] : visible;
|
||||
|
||||
const groups = groupByName(displayList);
|
||||
const groupNames = Object.keys(groups).filter((k) => k).sort();
|
||||
$('#list-groups-datalist').innerHTML = groupNames.map((g) => `<option value="${esc(g)}">`).join('');
|
||||
|
||||
const renderItem = (l) => `<div class="item-row">
|
||||
<strong class="grow">${esc(l.name)}</strong>
|
||||
<span>${l.wordCount} words${l.builtin ? ' · built-in' : ''}</span>
|
||||
<button class="secondary small" data-print-list="${l.id}">🖨 Worksheet</button>
|
||||
<button data-edit-list="${l.id}">${l.builtin ? 'Copy & edit' : 'Edit'}</button>
|
||||
<button class="danger" data-del-list="${l.id}">Delete</button>
|
||||
</div>`;
|
||||
const renderItem = (l) => {
|
||||
const isHidden = hiddenLists.has(l.id);
|
||||
return `<div class="item-row${isHidden ? ' list-hidden-item' : ''}">
|
||||
<strong class="grow">${esc(l.name)}</strong>
|
||||
<span class="muted-label">${l.wordCount} words${l.builtin ? ' · built-in' : ''}</span>
|
||||
<button class="secondary small" data-assign-list="${l.id}">Assign →</button>
|
||||
${l.builtin ? '' : `<button class="secondary small" data-print-list="${l.id}">🖨 Worksheet</button>`}
|
||||
<button class="small" data-edit-list="${l.id}">${l.builtin ? 'Copy & edit' : 'Edit'}</button>
|
||||
${l.builtin
|
||||
? `<button class="secondary small" data-hide-list="${l.id}">${isHidden ? 'Unhide' : 'Hide'}</button>`
|
||||
: `<button class="danger small" data-del-list="${l.id}">Delete</button>`}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
$('#list-rows').innerHTML = renderGroupSections(groups, renderItem);
|
||||
|
||||
document.querySelectorAll('[data-assign-list]').forEach((btn) =>
|
||||
btn.addEventListener('click', (e) => openAssignPopup(e, Number(btn.dataset.assignList)))
|
||||
);
|
||||
document.querySelectorAll('[data-print-list]').forEach((btn) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
const list = await api(`/api/lists/${btn.dataset.printList}`);
|
||||
printSpellingWorksheet(list.name, list.words);
|
||||
})
|
||||
);
|
||||
|
||||
document.querySelectorAll('[data-edit-list]').forEach((btn) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
const list = await api(`/api/lists/${btn.dataset.editList}`);
|
||||
@@ -1379,8 +1408,48 @@ function renderLists(lists) {
|
||||
loadSpelling();
|
||||
})
|
||||
);
|
||||
document.querySelectorAll('[data-hide-list]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => {
|
||||
const id = Number(btn.dataset.hideList);
|
||||
if (hiddenLists.has(id)) hiddenLists.delete(id); else hiddenLists.add(id);
|
||||
saveHiddenLists();
|
||||
renderLists(cachedLists, $('#list-filter').value);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Filter input
|
||||
$('#list-filter').addEventListener('input', () => renderLists(cachedLists, $('#list-filter').value));
|
||||
$('#list-show-hidden').addEventListener('click', () => {
|
||||
const btn = $('#list-show-hidden');
|
||||
btn.dataset.showing = btn.dataset.showing === '1' ? '0' : '1';
|
||||
renderLists(cachedLists, $('#list-filter').value);
|
||||
});
|
||||
|
||||
// ---- Assign popup ----
|
||||
function openAssignPopup(e, listId) {
|
||||
const popup = $('#assign-popup');
|
||||
$('#assign-popup-students').innerHTML = cachedStudents.map((s) =>
|
||||
`<button class="assign-popup-student" data-sid="${s.id}" data-lid="${listId}">${esc(s.emoji)} ${esc(s.name)}</button>`
|
||||
).join('');
|
||||
document.querySelectorAll('.assign-popup-student').forEach((btn) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
await api('/api/assign', { method: 'POST', body: { studentId: Number(btn.dataset.sid), listId: Number(btn.dataset.lid) } });
|
||||
popup.hidden = true;
|
||||
msg(`Assigned!`);
|
||||
loadSpelling();
|
||||
})
|
||||
);
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
popup.style.top = `${rect.bottom + window.scrollY + 4}px`;
|
||||
popup.style.left = `${rect.left + window.scrollX}px`;
|
||||
popup.hidden = false;
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!$('#assign-popup').hidden && !e.target.closest('#assign-popup') && !e.target.dataset.assignList)
|
||||
$('#assign-popup').hidden = true;
|
||||
});
|
||||
|
||||
$('#list-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = $('#list-name').value.trim();
|
||||
@@ -1563,7 +1632,13 @@ async function printSpellingReport(testId) {
|
||||
async function loadDecks() {
|
||||
const decks = await api('/api/decks');
|
||||
cachedDecks = decks;
|
||||
const groups = groupByName(decks);
|
||||
renderDecks(decks, $('#deck-filter').value);
|
||||
}
|
||||
|
||||
function renderDecks(decks, filterText = '') {
|
||||
const q = filterText.toLowerCase();
|
||||
const visible = decks.filter((d) => !q || d.name.toLowerCase().includes(q));
|
||||
const groups = groupByName(visible);
|
||||
const groupNames = Object.keys(groups).filter((k) => k).sort();
|
||||
$('#deck-groups-datalist').innerHTML = groupNames.map((g) => `<option value="${esc(g)}">`).join('');
|
||||
|
||||
@@ -1597,6 +1672,8 @@ async function loadDecks() {
|
||||
);
|
||||
}
|
||||
|
||||
$('#deck-filter').addEventListener('input', () => renderDecks(cachedDecks, $('#deck-filter').value));
|
||||
|
||||
$('#deck-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = $('#deck-name').value.trim();
|
||||
|
||||
@@ -220,6 +220,7 @@ button.danger { background: var(--bad); }
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.item-row .grow { flex: 1; min-width: 120px; }
|
||||
.muted-label { font-size: 0.82rem; color: var(--muted); white-space: nowrap; }
|
||||
.item-row select { padding: 0.4rem; border-radius: 8px; border: 2px solid #d8d2c0; font-family: inherit; }
|
||||
.item-row button {
|
||||
font-size: 0.9rem; font-family: inherit; border: none; border-radius: 8px;
|
||||
@@ -338,6 +339,37 @@ table.results tr:nth-child(even) td { background: #faf7ee; }
|
||||
.admin-main h2 { margin: 1.5rem 0 0.5rem; font-size: 1.15rem; }
|
||||
.panel { text-align: left; }
|
||||
|
||||
/* ---------- filter bar ---------- */
|
||||
.filter-bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; }
|
||||
.filter-bar input[type="search"] {
|
||||
flex: 1; font-family: inherit; font-size: 0.95rem;
|
||||
padding: 0.4rem 0.7rem; border: 2px solid #d8d2c0; border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ---------- mastery progress ---------- */
|
||||
.mastery-stat { font-size: 0.85rem; color: var(--muted); display: flex; align-items: center; gap: 0.4rem; white-space: nowrap; }
|
||||
.mastery-bar { display: inline-block; width: 70px; height: 7px; background: #e8e2d0; border-radius: 4px; overflow: hidden; flex-shrink: 0; }
|
||||
.mastery-fill { display: block; height: 100%; background: var(--accent); border-radius: 4px; transition: width 0.3s; }
|
||||
|
||||
/* ---------- assign popup ---------- */
|
||||
#assign-popup {
|
||||
position: absolute; z-index: 200;
|
||||
background: var(--card); border: 2px solid #d8d2c0; border-radius: 12px;
|
||||
padding: 0.6rem; box-shadow: 0 4px 18px rgba(0,0,0,0.15);
|
||||
min-width: 160px;
|
||||
}
|
||||
.assign-popup-label { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin: 0 0 0.4rem; }
|
||||
#assign-popup-students { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.assign-popup-student {
|
||||
font-size: 0.95rem; font-family: inherit; font-weight: 600;
|
||||
background: #f5f1e8; color: var(--ink); border: none;
|
||||
border-radius: 8px; padding: 0.45rem 0.8rem; cursor: pointer; text-align: left;
|
||||
}
|
||||
.assign-popup-student:hover { background: var(--accent); color: #fff; }
|
||||
|
||||
/* ---------- hidden list items ---------- */
|
||||
.list-hidden-item { opacity: 0.45; }
|
||||
|
||||
/* ---------- toggle button rows (new/bulk import) ---------- */
|
||||
.toggle-btns { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||||
.toggle-btns button.active { background: var(--accent) !important; color: #fff !important; }
|
||||
|
||||
@@ -436,6 +436,15 @@ app.get('/api/overview', requirePin, (req, res) => {
|
||||
s.mastered = db.prepare(
|
||||
`SELECT COUNT(*) AS n FROM progress WHERE student_id = ? AND box >= ${MASTERED_BOX}`
|
||||
).get(s.id).n;
|
||||
if (s.assignment) {
|
||||
s.listProgress = db.prepare(`
|
||||
SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN COALESCE(p.box, 0) >= ${MASTERED_BOX} THEN 1 ELSE 0 END) AS mastered
|
||||
FROM words w
|
||||
LEFT JOIN progress p ON p.word_id = w.id AND p.student_id = ?
|
||||
WHERE w.list_id = ?
|
||||
`).get(s.id, s.assignment.id);
|
||||
}
|
||||
s.tests = db.prepare(`
|
||||
SELECT t.id, t.score, t.total, t.at, l.name AS list
|
||||
FROM tests t JOIN lists l ON l.id = t.list_id
|
||||
|
||||
Reference in new issue
Block a user