Add group/course labels to spelling lists and flashcard decks
- group_name column added to lists and decks tables - GET/POST/PUT endpoints for both include group_name - Admin Spelling and Flashcards panels render items grouped under labeled section headers (e.g. Bible 6, Bible 8) with ungrouped items shown last - Group input in each form has datalist autocomplete from existing groups - Edit/copy populates the group field; Clear resets it Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
0ae0db76bc
commit
9acd0a792f
5 files changed
+83
-30
No files matched your search
@@ -228,6 +228,8 @@ tryAlter(`ALTER TABLE students ADD COLUMN theme TEXT NOT NULL DEFAULT 'blue'`);
|
||||
tryAlter(`ALTER TABLE students ADD COLUMN streak_date TEXT`);
|
||||
tryAlter(`ALTER TABLE students ADD COLUMN streak_count INTEGER NOT NULL DEFAULT 0`);
|
||||
tryAlter(`ALTER TABLE students ADD COLUMN best_streak INTEGER NOT NULL DEFAULT 0`);
|
||||
tryAlter(`ALTER TABLE lists ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
|
||||
tryAlter(`ALTER TABLE decks ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`);
|
||||
|
||||
export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex');
|
||||
|
||||
|
||||
@@ -287,6 +287,8 @@
|
||||
<form id="list-form" class="admin-card">
|
||||
<input type="hidden" id="list-id">
|
||||
<input id="list-name" placeholder="List name (e.g. Week of July 6)" aria-label="List name">
|
||||
<input id="list-group" placeholder="Group (e.g. Bible 6)" aria-label="Group" list="list-groups-datalist">
|
||||
<datalist id="list-groups-datalist"></datalist>
|
||||
<textarea id="list-words" rows="8"
|
||||
placeholder="One word per line. Paste any list from your curriculum! Add an example sentence with | receive | Did you receive my letter? Or just a plain list: because enough friend"></textarea>
|
||||
<div class="row-actions">
|
||||
@@ -309,6 +311,8 @@
|
||||
<form id="deck-form" class="admin-card">
|
||||
<input type="hidden" id="deck-id">
|
||||
<input id="deck-name" placeholder="Deck name (e.g. State Capitals)" aria-label="Deck name">
|
||||
<input id="deck-group" placeholder="Group (e.g. Bible 6)" aria-label="Group" list="deck-groups-datalist">
|
||||
<datalist id="deck-groups-datalist"></datalist>
|
||||
<textarea id="deck-cards" rows="8"
|
||||
placeholder="One card per line: front | back What is the capital of Ohio? | Columbus"></textarea>
|
||||
<div class="row-actions">
|
||||
|
||||
+49
-16
@@ -1309,18 +1309,43 @@ function renderAssignRows(students) {
|
||||
);
|
||||
}
|
||||
|
||||
function groupByName(arr) {
|
||||
const map = {};
|
||||
for (const item of arr) {
|
||||
const k = item.group_name || '';
|
||||
if (!map[k]) map[k] = [];
|
||||
map[k].push(item);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function renderGroupSections(groups, renderItem) {
|
||||
const named = Object.keys(groups).filter((k) => k).sort();
|
||||
const ungrouped = groups[''] || [];
|
||||
let html = '';
|
||||
for (const g of named) {
|
||||
html += `<div class="list-group"><div class="list-group-header">${esc(g)}</div>${groups[g].map(renderItem).join('')}</div>`;
|
||||
}
|
||||
if (ungrouped.length) {
|
||||
html += `<div class="list-group">${named.length ? '<div class="list-group-header">Ungrouped</div>' : ''}${ungrouped.map(renderItem).join('')}</div>`;
|
||||
}
|
||||
return html || '<p class="hint">None yet.</p>';
|
||||
}
|
||||
|
||||
function renderLists(lists) {
|
||||
$('#list-rows').innerHTML = lists
|
||||
.map(
|
||||
(l) => `<div class="item-row">
|
||||
const groups = groupByName(lists);
|
||||
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>`
|
||||
)
|
||||
.join('');
|
||||
</div>`;
|
||||
|
||||
$('#list-rows').innerHTML = renderGroupSections(groups, renderItem);
|
||||
|
||||
document.querySelectorAll('[data-print-list]').forEach((btn) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
@@ -1334,6 +1359,7 @@ function renderLists(lists) {
|
||||
const list = await api(`/api/lists/${btn.dataset.editList}`);
|
||||
$('#list-id').value = list.builtin ? '' : list.id;
|
||||
$('#list-name').value = list.builtin ? `${list.name} (copy)` : list.name;
|
||||
$('#list-group').value = list.group_name || '';
|
||||
$('#list-words').value = list.words.map((w) => {
|
||||
if (w.definition) return `${w.word} | ${w.sentence} | ${w.definition}`;
|
||||
if (w.sentence) return `${w.word} | ${w.sentence}`;
|
||||
@@ -1365,9 +1391,10 @@ $('#list-form').addEventListener('submit', async (e) => {
|
||||
return { word, sentence, definition };
|
||||
});
|
||||
if (!name || words.length === 0) return msg('Give the list a name and at least one word.');
|
||||
const groupName = $('#list-group').value.trim();
|
||||
const id = $('#list-id').value;
|
||||
if (id) await api(`/api/lists/${id}`, { method: 'PUT', body: { name, words } });
|
||||
else await api('/api/lists', { method: 'POST', body: { name, words } });
|
||||
if (id) await api(`/api/lists/${id}`, { method: 'PUT', body: { name, words, groupName } });
|
||||
else await api('/api/lists', { method: 'POST', body: { name, words, groupName } });
|
||||
clearListForm();
|
||||
msg('List saved.');
|
||||
loadSpelling();
|
||||
@@ -1376,6 +1403,7 @@ $('#list-cancel').addEventListener('click', clearListForm);
|
||||
function clearListForm() {
|
||||
$('#list-id').value = '';
|
||||
$('#list-name').value = '';
|
||||
$('#list-group').value = '';
|
||||
$('#list-words').value = '';
|
||||
$('#list-editor-title').textContent = 'New list';
|
||||
}
|
||||
@@ -1466,22 +1494,25 @@ async function printSpellingReport(testId) {
|
||||
async function loadDecks() {
|
||||
const decks = await api('/api/decks');
|
||||
cachedDecks = decks;
|
||||
$('#deck-rows').innerHTML = decks
|
||||
.map(
|
||||
(d) => `<div class="item-row">
|
||||
const groups = groupByName(decks);
|
||||
const groupNames = Object.keys(groups).filter((k) => k).sort();
|
||||
$('#deck-groups-datalist').innerHTML = groupNames.map((g) => `<option value="${esc(g)}">`).join('');
|
||||
|
||||
const renderItem = (d) => `<div class="item-row">
|
||||
<strong class="grow">${esc(d.name)}</strong>
|
||||
<span>${d.cardCount} cards${d.builtin ? ' · built-in' : ''}</span>
|
||||
<button data-edit-deck="${d.id}">${d.builtin ? 'Copy & edit' : 'Edit'}</button>
|
||||
<button class="danger" data-del-deck="${d.id}">Delete</button>
|
||||
</div>`
|
||||
)
|
||||
.join('');
|
||||
</div>`;
|
||||
|
||||
$('#deck-rows').innerHTML = renderGroupSections(groups, renderItem);
|
||||
|
||||
document.querySelectorAll('[data-edit-deck]').forEach((btn) =>
|
||||
btn.addEventListener('click', async () => {
|
||||
const deck = await api(`/api/decks/${btn.dataset.editDeck}`);
|
||||
$('#deck-id').value = deck.builtin ? '' : deck.id;
|
||||
$('#deck-name').value = deck.builtin ? `${deck.name} (copy)` : deck.name;
|
||||
$('#deck-group').value = deck.group_name || '';
|
||||
$('#deck-cards').value = deck.cards.map((c) => `${c.front} | ${c.back}`).join('\n');
|
||||
$('#deck-editor-title').textContent = deck.builtin ? 'New deck (from copy)' : `Editing: ${deck.name}`;
|
||||
$('#deck-editor-details').open = true;
|
||||
@@ -1509,9 +1540,10 @@ $('#deck-form').addEventListener('submit', async (e) => {
|
||||
return { front, back };
|
||||
});
|
||||
if (!name || cards.length === 0) return msg('Give the deck a name and at least one card.');
|
||||
const groupName = $('#deck-group').value.trim();
|
||||
const id = $('#deck-id').value;
|
||||
if (id) await api(`/api/decks/${id}`, { method: 'PUT', body: { name, cards } });
|
||||
else await api('/api/decks', { method: 'POST', body: { name, cards } });
|
||||
if (id) await api(`/api/decks/${id}`, { method: 'PUT', body: { name, cards, groupName } });
|
||||
else await api('/api/decks', { method: 'POST', body: { name, cards, groupName } });
|
||||
clearDeckForm();
|
||||
msg('Deck saved.');
|
||||
loadDecks();
|
||||
@@ -1520,6 +1552,7 @@ $('#deck-cancel').addEventListener('click', clearDeckForm);
|
||||
function clearDeckForm() {
|
||||
$('#deck-id').value = '';
|
||||
$('#deck-name').value = '';
|
||||
$('#deck-group').value = '';
|
||||
$('#deck-cards').value = '';
|
||||
$('#deck-editor-title').textContent = 'New deck';
|
||||
}
|
||||
|
||||
@@ -354,6 +354,16 @@ table.results tr:nth-child(even) td { background: #faf7ee; }
|
||||
.color-dot { width: 14px; height: 14px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.archived-badge { background: #e8e2d0; color: var(--muted); font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 999px; }
|
||||
|
||||
/* ---------- list/deck groups ---------- */
|
||||
.list-group { margin-bottom: 0.75rem; }
|
||||
.list-group-header {
|
||||
font-size: 0.78rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--muted);
|
||||
padding: 0.5rem 0.25rem 0.2rem;
|
||||
border-bottom: 1.5px solid #e8e2d0;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* ---------- roster chips ---------- */
|
||||
#cd-roster { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
#cd-roster .checkbox-label {
|
||||
|
||||
@@ -267,37 +267,39 @@ app.get('/api/test-report/:testId', (req, res) => {
|
||||
|
||||
app.get('/api/lists', (req, res) => {
|
||||
res.json(db.prepare(`
|
||||
SELECT l.id, l.name, l.builtin, COUNT(w.id) AS wordCount
|
||||
SELECT l.id, l.name, l.builtin, l.group_name, COUNT(w.id) AS wordCount
|
||||
FROM lists l LEFT JOIN words w ON w.list_id = l.id
|
||||
GROUP BY l.id ORDER BY l.builtin, l.name
|
||||
GROUP BY l.id ORDER BY l.group_name, l.builtin, l.name
|
||||
`).all());
|
||||
});
|
||||
|
||||
app.get('/api/lists/:id', (req, res) => {
|
||||
const list = db.prepare(`SELECT id, name, builtin FROM lists WHERE id = ?`).get(req.params.id);
|
||||
const list = db.prepare(`SELECT id, name, builtin, group_name FROM lists WHERE id = ?`).get(req.params.id);
|
||||
if (!list) return res.status(404).json({ error: 'No such list' });
|
||||
list.words = db.prepare(`SELECT id, word, sentence, definition FROM words WHERE list_id = ? ORDER BY id`).all(req.params.id);
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
app.post('/api/lists', requirePin, (req, res) => {
|
||||
const { name, words } = req.body;
|
||||
const { name, words, groupName } = req.body;
|
||||
if (!name || !Array.isArray(words) || words.length === 0) {
|
||||
return res.status(400).json({ error: 'Name and at least one word required' });
|
||||
}
|
||||
const id = db.prepare(`INSERT INTO lists (name, builtin) VALUES (?, 0)`).run(String(name).trim()).lastInsertRowid;
|
||||
const id = db.prepare(`INSERT INTO lists (name, builtin, group_name) VALUES (?, 0, ?)`)
|
||||
.run(String(name).trim(), String(groupName || '').trim()).lastInsertRowid;
|
||||
const ins = db.prepare(`INSERT INTO words (list_id, word, sentence, definition) VALUES (?, ?, ?, ?)`);
|
||||
for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim(), String(w.definition || '').trim());
|
||||
res.json({ id });
|
||||
});
|
||||
|
||||
app.put('/api/lists/:id', requirePin, (req, res) => {
|
||||
const { name, words } = req.body;
|
||||
const { name, words, groupName } = req.body;
|
||||
const id = req.params.id;
|
||||
if (!db.prepare(`SELECT id FROM lists WHERE id = ?`).get(id)) {
|
||||
return res.status(404).json({ error: 'No such list' });
|
||||
}
|
||||
db.prepare(`UPDATE lists SET name = ? WHERE id = ?`).run(String(name).trim(), id);
|
||||
db.prepare(`UPDATE lists SET name = ?, group_name = ? WHERE id = ?`)
|
||||
.run(String(name).trim(), String(groupName || '').trim(), id);
|
||||
db.prepare(`DELETE FROM words WHERE list_id = ?`).run(id);
|
||||
const ins = db.prepare(`INSERT INTO words (list_id, word, sentence, definition) VALUES (?, ?, ?, ?)`);
|
||||
for (const w of words) ins.run(id, String(w.word).trim(), String(w.sentence || '').trim(), String(w.definition || '').trim());
|
||||
@@ -313,37 +315,39 @@ app.delete('/api/lists/:id', requirePin, (req, res) => {
|
||||
|
||||
app.get('/api/decks', (req, res) => {
|
||||
res.json(db.prepare(`
|
||||
SELECT d.id, d.name, d.builtin, COUNT(c.id) AS cardCount
|
||||
SELECT d.id, d.name, d.builtin, d.group_name, COUNT(c.id) AS cardCount
|
||||
FROM decks d LEFT JOIN cards c ON c.deck_id = d.id
|
||||
GROUP BY d.id ORDER BY d.builtin, d.name
|
||||
GROUP BY d.id ORDER BY d.group_name, d.builtin, d.name
|
||||
`).all());
|
||||
});
|
||||
|
||||
app.get('/api/decks/:id', (req, res) => {
|
||||
const deck = db.prepare(`SELECT id, name, builtin FROM decks WHERE id = ?`).get(req.params.id);
|
||||
const deck = db.prepare(`SELECT id, name, builtin, group_name FROM decks WHERE id = ?`).get(req.params.id);
|
||||
if (!deck) return res.status(404).json({ error: 'No such deck' });
|
||||
deck.cards = db.prepare(`SELECT id, front, back FROM cards WHERE deck_id = ? ORDER BY id`).all(req.params.id);
|
||||
res.json(deck);
|
||||
});
|
||||
|
||||
app.post('/api/decks', requirePin, (req, res) => {
|
||||
const { name, cards } = req.body;
|
||||
const { name, cards, groupName } = req.body;
|
||||
if (!name || !Array.isArray(cards) || cards.length === 0) {
|
||||
return res.status(400).json({ error: 'Name and at least one card required' });
|
||||
}
|
||||
const id = db.prepare(`INSERT INTO decks (name, builtin) VALUES (?, 0)`).run(String(name).trim()).lastInsertRowid;
|
||||
const id = db.prepare(`INSERT INTO decks (name, builtin, group_name) VALUES (?, 0, ?)`)
|
||||
.run(String(name).trim(), String(groupName || '').trim()).lastInsertRowid;
|
||||
const ins = db.prepare(`INSERT INTO cards (deck_id, front, back) VALUES (?, ?, ?)`);
|
||||
for (const c of cards) ins.run(id, String(c.front).trim(), String(c.back).trim());
|
||||
res.json({ id });
|
||||
});
|
||||
|
||||
app.put('/api/decks/:id', requirePin, (req, res) => {
|
||||
const { name, cards } = req.body;
|
||||
const { name, cards, groupName } = req.body;
|
||||
const id = req.params.id;
|
||||
if (!db.prepare(`SELECT id FROM decks WHERE id = ?`).get(id)) {
|
||||
return res.status(404).json({ error: 'No such deck' });
|
||||
}
|
||||
db.prepare(`UPDATE decks SET name = ? WHERE id = ?`).run(String(name).trim(), id);
|
||||
db.prepare(`UPDATE decks SET name = ?, group_name = ? WHERE id = ?`)
|
||||
.run(String(name).trim(), String(groupName || '').trim(), id);
|
||||
db.prepare(`DELETE FROM cards WHERE deck_id = ?`).run(id);
|
||||
const ins = db.prepare(`INSERT INTO cards (deck_id, front, back) VALUES (?, ?, ?)`);
|
||||
for (const c of cards) ins.run(id, String(c.front).trim(), String(c.back).trim());
|
||||
|
||||
Reference in new issue
Block a user