diff --git a/db.js b/db.js index 8710cb6..14d8ea1 100644 --- a/db.js +++ b/db.js @@ -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'); diff --git a/public/admin.html b/public/admin.html index a94c9ea..20fc2ec 100644 --- a/public/admin.html +++ b/public/admin.html @@ -287,6 +287,8 @@
+ +
@@ -309,6 +311,8 @@ + +
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 += `
${esc(g)}
${groups[g].map(renderItem).join('')}
`; + } + if (ungrouped.length) { + html += `
${named.length ? '
Ungrouped
' : ''}${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());