diff --git a/db.js b/db.js index 4a21946..406f5b2 100644 --- a/db.js +++ b/db.js @@ -206,6 +206,15 @@ db.exec(` answers TEXT, completed_at TEXT NOT NULL DEFAULT (datetime('now')) ); + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, + sender TEXT NOT NULL, + body TEXT NOT NULL, + sent_at TEXT NOT NULL DEFAULT (datetime('now')), + read_at TEXT + ); `); function tryAlter(sql) { diff --git a/public/admin.html b/public/admin.html index e6f4373..a9f9e0f 100644 --- a/public/admin.html +++ b/public/admin.html @@ -28,6 +28,7 @@ π Courses ποΈ Planner β Grading + π¬ Messages π Gradebook βοΈ Spelling ποΈ Flashcards @@ -376,6 +377,17 @@ + + + Messages + + + + Select a student to view the conversation. + + + + Gradebook diff --git a/public/admin.js b/public/admin.js index 8f6c5bb..d678ee9 100644 --- a/public/admin.js +++ b/public/admin.js @@ -78,6 +78,7 @@ async function unlock() { $('#console').hidden = false; showPanel('kids'); loadGradingBadge(); + loadMessagesBadge(); } async function loadGradingBadge() { @@ -110,7 +111,7 @@ function showPanel(name) { document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.panel === name)); const loaders = { kids: loadKids, courses: loadCourses, planner: loadPlannerPanel, - grading: loadGrading, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks, + grading: loadGrading, messages: loadMessages, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks, settings: loadSettings, }; if (loaders[name]) loaders[name](); @@ -1863,6 +1864,26 @@ async function loadGrading() { const rows = filterEl.value ? allRows.filter((r) => String(r.studentId) === filterEl.value) : allRows; + function renderShortAnswerBlock(r) { + if (r.itemType !== 'worksheet' || !r.body || !r.answers) return ''; + let wd, answers; + try { wd = JSON.parse(r.body); } catch { return ''; } + try { answers = JSON.parse(r.answers); } catch { answers = {}; } + const rows = []; + (wd.sections || []).forEach((section, si) => { + (section.questions || []).forEach((q, qi) => { + if (q.type !== 'short') return; + const response = (answers[`${si}:${qi}`] || '').trim(); + rows.push(` + ${esc(q.text)} + ${response ? esc(response) : 'No answer written'} + `); + }); + }); + if (!rows.length) return ''; + return `Short answers${rows.join('')}`; + } + $('#grading-empty').hidden = rows.length > 0; $('#grading-rows').innerHTML = rows .map((r) => { @@ -1879,6 +1900,7 @@ async function loadGrading() { ${esc(r.courseName)} Β· ${esc(r.unitName)} ${noteTag} + ${renderShortAnswerBlock(r)} ${evidenceBtn} + + + + No grades yet β check back after your parent reviews your work! + + + π¬ Messages + + + + Send β€ + + + diff --git a/public/styles.css b/public/styles.css index 7097818..1bd9e6d 100644 --- a/public/styles.css +++ b/public/styles.css @@ -987,6 +987,11 @@ td.overdue, .overdue { background: var(--bad-bg) !important; } .grading-footer { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: 0.6rem; } .comment-input { flex: 1; min-width: 140px; padding: 0.4rem 0.6rem; border: 1.5px solid #d8d2c0; border-radius: 8px; font-family: inherit; } .badge-tag { background: var(--gold); color: #333; border-radius: 20px; padding: 0.2rem 0.6rem; font-size: 0.8rem; font-weight: 600; } +.sa-block { background: var(--bg); border: 1px solid #e2dccf; border-radius: 10px; padding: 0.75rem; margin: 0.5rem 0; display: flex; flex-direction: column; gap: 0.6rem; } +.sa-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; } +.sa-question { font-size: 0.9rem; color: var(--muted); } +.sa-response { font-size: 0.95rem; font-weight: 600; color: var(--ink); white-space: pre-wrap; } +@media (max-width: 600px) { .sa-row { grid-template-columns: 1fr; } } /* ---------- themes ---------- */ body.theme-green { --accent: #2e9e5b; --accent-dark: #247a47; --bg: #f0faf4; } @@ -1246,6 +1251,45 @@ body.bg-bubbles { border-left: 4px solid var(--good); font-size: 1.05rem; } +.inbox-card { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 14px; padding: 1rem; margin-bottom: 0.75rem; } +.inbox-card.inbox-new { border-color: var(--accent); } +.inbox-card-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.25rem; } +.inbox-item-title { font-weight: 700; font-size: 1.05rem; flex: 1; } +.inbox-meta { font-size: 0.85rem; color: var(--muted); margin-bottom: 0.4rem; } +.inbox-score { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.25rem; } +.inbox-score.score-good { color: var(--good); } +.inbox-score.score-bad { color: var(--bad); } + +/* ---------- messaging ---------- */ +.msg-thread-wrap { margin-top: 1.5rem; background: var(--card); border: 1.5px solid #e2dccf; border-radius: 14px; overflow: hidden; } +.msg-thread-title { margin: 0; padding: .75rem 1rem; font-size: 1rem; border-bottom: 1px solid #e2dccf; } +.msg-thread { max-height: 320px; overflow-y: auto; padding: .75rem 1rem; display: flex; flex-direction: column; gap: .5rem; } +.msg-compose { display: flex; gap: .5rem; padding: .75rem 1rem; border-top: 1px solid #e2dccf; } +.msg-input { flex: 1; padding: .5rem .75rem; border: 1.5px solid #d8d2c0; border-radius: 10px; font-family: inherit; font-size: .95rem; resize: none; } +.msg-send-btn { padding: .5rem 1rem; border-radius: 10px; background: var(--accent); color: #fff; border: none; font-size: .9rem; cursor: pointer; white-space: nowrap; } +.msg-bubble-wrap { display: flex; flex-direction: column; max-width: 75%; } +.msg-mine { align-self: flex-end; align-items: flex-end; } +.msg-theirs { align-self: flex-start; align-items: flex-start; } +.msg-bubble { padding: .55rem .85rem; border-radius: 16px; font-size: .95rem; line-height: 1.4; white-space: pre-wrap; word-break: break-word; } +.msg-mine .msg-bubble { background: var(--accent); color: #fff; border-bottom-right-radius: 4px; } +.msg-theirs .msg-bubble { background: #e8e2d4; color: var(--ink); border-bottom-left-radius: 4px; } +.msg-time { font-size: .72rem; color: var(--muted); margin-top: .2rem; } + +/* admin messages panel */ +.msg-admin-cols { display: grid; grid-template-columns: 240px 1fr; gap: 1rem; height: calc(100vh - 180px); } +.msg-thread-list { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 12px; overflow-y: auto; display: flex; flex-direction: column; } +.msg-student-btn { background: none; border: none; border-bottom: 1px solid #e2dccf; padding: .75rem; text-align: left; cursor: pointer; display: flex; gap: .6rem; align-items: flex-start; } +.msg-student-btn:hover, .msg-student-btn.active { background: var(--accent-light, #e8f0fe); } +.msg-student-btn.msg-has-unread { font-weight: 700; } +.msg-student-avatar { font-size: 1.5rem; line-height: 1; } +.msg-student-info { display: flex; flex-direction: column; gap: .15rem; min-width: 0; } +.msg-preview { font-size: .8rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; font-weight: 400; } +.msg-nav-badge { background: var(--bad); color: #fff; border-radius: 999px; font-size: .72rem; font-weight: 800; padding: .1rem .45rem; margin-left: .35rem; } +.msg-admin-thread { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; } +.msg-admin-thread-header { padding: .75rem 1rem; border-bottom: 1px solid #e2dccf; font-size: 1rem; } +.msg-admin-msgs { flex: 1; overflow-y: auto; padding: .75rem 1rem; display: flex; flex-direction: column; gap: .5rem; } +.msg-admin-compose { display: flex; gap: .5rem; padding: .75rem 1rem; border-top: 1px solid #e2dccf; } +@media (max-width: 640px) { .msg-admin-cols { grid-template-columns: 1fr; height: auto; } } /* ---------- quiz / matching instructions ---------- */ .quiz-instructions { diff --git a/server.js b/server.js index b5b72a7..955e94a 100644 --- a/server.js +++ b/server.js @@ -1727,6 +1727,7 @@ app.get('/api/grading-queue', requirePin, (req, res) => { res.json(db.prepare(` SELECT sub.id AS submissionId, sub.completed_at, sub.points_possible, sub.evidence_notes, sub.evidence_photo, sub.student_note, + sub.answers, i.body, s.id AS studentId, s.name AS studentName, s.emoji, i.id AS itemId, i.title AS itemTitle, i.type AS itemType, c.name AS courseName, u.name AS unitName @@ -1754,6 +1755,76 @@ app.put('/api/submissions/:id/grade', requirePin, (req, res) => { res.json({ ok: true }); }); +// ---------- messages ---------- + +app.get('/api/students/:id/messages', (req, res) => { + const msgs = db.prepare(`SELECT id, sender, body, sent_at, read_at FROM messages WHERE student_id = ? ORDER BY sent_at`).all(req.params.id); + // Mark parent messages as read when student loads the thread + db.prepare(`UPDATE messages SET read_at = datetime('now') WHERE student_id = ? AND sender = 'parent' AND read_at IS NULL`).run(req.params.id); + res.json(msgs); +}); + +app.post('/api/students/:id/messages', (req, res) => { + const body = String(req.body.body || '').trim(); + if (!body) return res.status(400).json({ error: 'Message cannot be empty' }); + const student = db.prepare(`SELECT id FROM students WHERE id = ?`).get(req.params.id); + if (!student) return res.status(404).json({ error: 'No such student' }); + const { lastInsertRowid } = db.prepare(`INSERT INTO messages (student_id, sender, body) VALUES (?, 'student', ?)`).run(req.params.id, body); + res.json({ id: lastInsertRowid }); +}); + +app.post('/api/messages/:id/reply', requirePin, (req, res) => { + const body = String(req.body.body || '').trim(); + if (!body) return res.status(400).json({ error: 'Reply cannot be empty' }); + const msg = db.prepare(`SELECT student_id FROM messages WHERE id = ?`).get(req.params.id); + if (!msg) return res.status(404).json({ error: 'No such message' }); + const { lastInsertRowid } = db.prepare(`INSERT INTO messages (student_id, sender, body) VALUES (?, 'parent', ?)`).run(msg.student_id, body); + db.prepare(`UPDATE messages SET read_at = datetime('now') WHERE id = ?`).run(req.params.id); + res.json({ id: lastInsertRowid }); +}); + +app.get('/api/admin/messages', requirePin, (req, res) => { + const threads = db.prepare(` + SELECT s.id AS studentId, s.name AS studentName, s.emoji, + COUNT(CASE WHEN m.sender = 'student' AND m.read_at IS NULL THEN 1 END) AS unread, + MAX(m.sent_at) AS lastAt, + (SELECT body FROM messages WHERE student_id = s.id ORDER BY sent_at DESC LIMIT 1) AS lastBody + FROM students s + JOIN messages m ON m.student_id = s.id + GROUP BY s.id + ORDER BY lastAt DESC + `).all(); + res.json(threads); +}); + +app.get('/api/admin/messages/count', requirePin, (req, res) => { + const { n } = db.prepare(`SELECT COUNT(*) AS n FROM messages WHERE sender = 'student' AND read_at IS NULL`).get(); + res.json({ count: n }); +}); + +app.get('/api/admin/messages/:studentId', requirePin, (req, res) => { + const msgs = db.prepare(`SELECT id, sender, body, sent_at, read_at FROM messages WHERE student_id = ? ORDER BY sent_at`).all(req.params.studentId); + db.prepare(`UPDATE messages SET read_at = datetime('now') WHERE student_id = ? AND sender = 'student' AND read_at IS NULL`).run(req.params.studentId); + res.json(msgs); +}); + +// ---------- student inbox ---------- + +app.get('/api/students/:id/inbox', (req, res) => { + const items = db.prepare(` + SELECT sub.id AS submissionId, sub.score, sub.points_possible, sub.graded_at, sub.parent_comment, + i.title AS itemTitle, c.name AS courseName, u.name AS unitName + FROM submissions sub + JOIN items i ON i.id = sub.item_id + JOIN units u ON u.id = i.unit_id + JOIN courses c ON c.id = u.course_id + WHERE sub.student_id = ? AND sub.status = 'graded' AND sub.graded_at IS NOT NULL + ORDER BY sub.graded_at DESC + LIMIT 50 + `).all(req.params.id); + res.json(items); +}); + // ---------- gradebook (parent) ---------- app.get('/api/gradebook/:courseId', requirePin, (req, res) => {
Select a student to view the conversation.
No grades yet β check back after your parent reviews your work!