diff --git a/public/admin.js b/public/admin.js
index 2f86cc3..f7a7c14 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -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 += `
${groups[g].map(renderItem).join('')}
`;
+ }
+ if (ungrouped.length) {
+ html += `
${named.length ? '' : ''}${ungrouped.map(renderItem).join('')}
`;
+ }
+ return html || '
None yet.
';
+}
+
function renderLists(lists) {
- $('#list-rows').innerHTML = lists
- .map(
- (l) => `
+ const groups = groupByName(lists);
+ const groupNames = Object.keys(groups).filter((k) => k).sort();
+ $('#list-groups-datalist').innerHTML = groupNames.map((g) => `
`;
+
+ $('#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) => `
+ const groups = groupByName(decks);
+ const groupNames = Object.keys(groups).filter((k) => k).sort();
+ $('#deck-groups-datalist').innerHTML = groupNames.map((g) => `
`;
+
+ $('#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';
}
diff --git a/public/styles.css b/public/styles.css
index 6bf5cdc..6ad7c58 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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 {
diff --git a/server.js b/server.js
index c6ca6aa..5d5ed20 100644
--- a/server.js
+++ b/server.js
@@ -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());