From 2accefd122105e2af8ea4aeefb8eedebd037e36c Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:10:14 -0400 Subject: [PATCH] Add toast notifications for all saves and batch planner task add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toast: replace buried #admin-msg hint with a floating green/red toast (bottom-right, auto-dismisses after 3s) covering every save action — lists, decks, items, grades, kids, units, courses, bulk imports, and planner saves. Planner batch add: clicking '+ Add' now queues items locally (shown as dashed pending badges) without saving. A '💾 Save N items' button appears in the toolbar; clicking it commits everything at once and refreshes the planner. Queue clears on student change. Co-Authored-By: Claude Sonnet 4.6 --- public/admin.html | 4 ++- public/admin.js | 74 +++++++++++++++++++++++++++++++++++++++++------ public/styles.css | 14 +++++++++ 3 files changed, 82 insertions(+), 10 deletions(-) diff --git a/public/admin.html b/public/admin.html index e3fe368..1398459 100644 --- a/public/admin.html +++ b/public/admin.html @@ -244,6 +244,7 @@ +
@@ -448,7 +449,6 @@ -

@@ -458,6 +458,8 @@
+ + diff --git a/public/admin.js b/public/admin.js index 1a1718e..4d14238 100644 --- a/public/admin.js +++ b/public/admin.js @@ -21,9 +21,17 @@ async function api(path, opts = {}) { return data; } -function msg(text) { - $('#admin-msg').textContent = text; - setTimeout(() => ($('#admin-msg').textContent = ''), 4000); +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 = { @@ -135,6 +143,7 @@ async function loadKids() { 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(); }) ); @@ -146,6 +155,7 @@ $('#add-student-form').addEventListener('submit', async (e) => { if (!name) return; await api('/api/students', { method: 'POST', body: { name, emoji: $('#new-student-emoji').value } }); $('#new-student-name').value = ''; + msg('Kid added.'); loadKids(); }); @@ -249,6 +259,7 @@ async function openCourseDetail(id) { 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); }) ); @@ -290,6 +301,7 @@ $('#cd-duplicate').addEventListener('click', async () => { $('#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'); }); @@ -300,6 +312,7 @@ $('#add-unit-form').addEventListener('submit', async (e) => { if (!name) return; await api('/api/units', { method: 'POST', body: { courseId: currentCourseId, name } }); $('#new-unit-name').value = ''; + msg('Unit added.'); openCourseDetail(currentCourseId); }); @@ -359,6 +372,7 @@ async function openItemEditor(unitId, itemId) { $('#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 { @@ -702,6 +716,7 @@ function addDays(dateStr, n) { } let plannerWeekStart = mondayOf(new Date()); +let plannerQueue = []; let plannerStudentId = null; let plannerCoursesCache = []; let plannerMode = 'week'; // 'week' | 'month' @@ -750,6 +765,8 @@ $('#auto-schedule-btn').addEventListener('click', async () => { }); $('#planner-student').addEventListener('change', () => { + plannerQueue = []; + updatePlannerSaveBtn(); plannerStudentId = Number($('#planner-student').value); renderPlanner(); }); @@ -942,18 +959,52 @@ function toggleAddTaskForm(date) { } })(); - box.querySelector('.task-save-btn').addEventListener('click', async () => { + box.querySelector('.task-save-btn').addEventListener('click', () => { const itemId = courseSelect.value; const title = box.querySelector('.task-offline-input').value.trim(); if (!itemId && !title) return; - await api('/api/schedule', { - method: 'POST', - body: { studentId: plannerStudentId, date, itemId: itemId || null, title }, - }); - renderPlanner(); + 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`; @@ -1207,6 +1258,7 @@ async function loadGrading() { method: 'PUT', body: { score: Number(input.value), parentComment: commentInput?.value || '' }, }); + msg('Grade saved.'); loadGrading(); loadGradingBadge(); }); @@ -1453,6 +1505,7 @@ function renderLists(lists, filterText = '') { btn.addEventListener('click', async () => { if (!confirm('Delete this list?')) return; await api(`/api/lists/${btn.dataset.delList}`, { method: 'DELETE' }); + msg('List deleted.'); loadSpelling(); }) ); @@ -1585,6 +1638,7 @@ $('#list-bulk-save').addEventListener('click', async () => { statusEl.textContent = `Imported ${done} of ${sections.length}…`; } statusEl.textContent = `Done! ${done} list(s) imported.`; + msg(`${done} list${done === 1 ? '' : 's'} imported.`); $('#list-bulk-text').value = ''; loadSpelling(); }); @@ -1693,6 +1747,7 @@ function renderDecks(decks, filterText = '') { btn.addEventListener('click', async () => { if (!confirm('Delete this deck?')) return; await api(`/api/decks/${btn.dataset.delDeck}`, { method: 'DELETE' }); + msg('Deck deleted.'); loadDecks(); }) ); @@ -1758,6 +1813,7 @@ $('#deck-bulk-save').addEventListener('click', async () => { statusEl.textContent = `Imported ${done} of ${sections.length}…`; } statusEl.textContent = `Done! ${done} deck(s) imported.`; + msg(`${done} deck${done === 1 ? '' : 's'} imported.`); $('#deck-bulk-text').value = ''; loadDecks(); }); diff --git a/public/styles.css b/public/styles.css index c716679..3a932aa 100644 --- a/public/styles.css +++ b/public/styles.css @@ -732,3 +732,17 @@ body.theme-pink { --accent: #d45d8a; --accent-dark: #b4487a; --bg: #fff0f5; } .thr-date { color: var(--muted); min-width: 6rem; flex-shrink: 0; } .thr-list { flex: 1; min-width: 8rem; } .thr-score { margin-left: auto; font-weight: 600; white-space: nowrap; } + +/* ---------- toast notification ---------- */ +#toast { + position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999; + background: var(--good); color: #fff; + padding: 0.7rem 1.2rem; border-radius: 12px; + font-weight: 600; font-size: 0.95rem; + box-shadow: 0 4px 18px rgba(0,0,0,0.18); + transition: opacity 0.35s, transform 0.35s; + opacity: 1; transform: translateY(0); +} +#toast.toast-error { background: var(--bad); } +#toast.toast-out { opacity: 0; transform: translateY(0.5rem); } +.planner-pending { border: 2px dashed var(--accent) !important; background: color-mix(in srgb, var(--accent) 10%, var(--card)) !important; color: var(--accent) !important; opacity: 0.85; cursor: default; }