diff --git a/public/admin.html b/public/admin.html index 6175c1b..a94c9ea 100644 --- a/public/admin.html +++ b/public/admin.html @@ -76,6 +76,7 @@ + diff --git a/public/admin.js b/public/admin.js index 9f11c5f..2f86cc3 100644 --- a/public/admin.js +++ b/public/admin.js @@ -280,6 +280,13 @@ $('#course-meta-form').addEventListener('submit', async (e) => { openCourseDetail(currentCourseId); }); +$('#cd-duplicate').addEventListener('click', async () => { + const { id } = await api(`/api/courses/${currentCourseId}/duplicate`, { method: 'POST' }); + currentCourseId = id; + showPanel('course-detail'); + msg('Course duplicated — update the name and details below.'); +}); + $('#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' }); diff --git a/public/styles.css b/public/styles.css index dd6df45..6bf5cdc 100644 --- a/public/styles.css +++ b/public/styles.css @@ -214,9 +214,9 @@ button.danger { background: var(--bad); } .inline-form input { flex: 1; min-width: 140px; } .item-row { - display: flex; align-items: center; gap: 0.75rem; - background: var(--card); border-radius: 12px; padding: 0.75rem 1rem; margin: 0.5rem 0; - box-shadow: 0 2px 6px rgba(45, 49, 66, 0.06); + display: flex; align-items: center; gap: 0.6rem; + background: var(--card); border-radius: 10px; padding: 0.45rem 0.75rem; margin: 0.2rem 0; + box-shadow: 0 1px 4px rgba(45, 49, 66, 0.06); flex-wrap: wrap; } .item-row .grow { flex: 1; min-width: 120px; } @@ -349,9 +349,29 @@ table.results tr:nth-child(even) td { background: #faf7ee; } } .checkbox-label { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: 400; margin-right: 1rem; } +/* admin-card sets label { flex-direction: column } — override for checkboxes */ +.admin-card .checkbox-label { flex-direction: row; font-size: 1rem; } .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; } +/* ---------- roster chips ---------- */ +#cd-roster { display: flex; flex-wrap: wrap; gap: 0.5rem; } +#cd-roster .checkbox-label { + margin-right: 0; + background: #f7f2e3; border: 1.5px solid #d8d2c0; border-radius: 999px; + padding: 0.3rem 0.85rem; cursor: pointer; font-size: 0.95rem; + transition: background 0.12s, border-color 0.12s; +} +#cd-roster .checkbox-label:hover { border-color: var(--accent); } + +/* ---------- admin list panels — 2-col grid on wider screens ---------- */ +#course-rows, #student-rows { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 0.4rem; +} +#course-rows .item-row, #student-rows .item-row { margin: 0; } + button.small { font-size: 0.85rem; padding: 0.4rem 0.8rem; } button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; } .hint.tiny { font-size: 0.8rem; margin: 0.25rem 0; } diff --git a/server.js b/server.js index caf09bf..c6ca6aa 100644 --- a/server.js +++ b/server.js @@ -479,6 +479,43 @@ app.delete('/api/courses/:id', requirePin, (req, res) => { res.json({ ok: true }); }); +app.post('/api/courses/:id/duplicate', requirePin, (req, res) => { + const src = db.prepare(`SELECT name, subject, color FROM courses WHERE id = ?`).get(req.params.id); + if (!src) return res.status(404).json({ error: 'No such course' }); + + const newCourseId = db.prepare(`INSERT INTO courses (name, subject, color) VALUES (?, ?, ?)`) + .run(`Copy of ${src.name}`, src.subject || '', src.color || '#4f86f7').lastInsertRowid; + + const insUnit = db.prepare(`INSERT INTO units (course_id, name, sort) VALUES (?, ?, ?)`); + const insItem = db.prepare(` + INSERT INTO items (unit_id, type, title, body, points, ref_id, sort, due_date, allow_retakes, prereq_item_id, evidence_mode, retake_policy) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const insQ = db.prepare(`INSERT INTO quiz_questions (item_id, type, prompt, choices, correct_answer, points, sort) VALUES (?, ?, ?, ?, ?, ?, ?)`); + const getItems = db.prepare(`SELECT * FROM items WHERE unit_id = ? ORDER BY sort, id`); + const getQs = db.prepare(`SELECT * FROM quiz_questions WHERE item_id = ? ORDER BY sort, id`); + + const units = db.prepare(`SELECT id, name, sort FROM units WHERE course_id = ? ORDER BY sort, id`).all(req.params.id); + for (const u of units) { + const newUnitId = insUnit.run(newCourseId, u.name, u.sort).lastInsertRowid; + for (const it of getItems.all(u.id)) { + const newItemId = insItem.run( + newUnitId, it.type, it.title, it.body, it.points, it.ref_id, + it.sort, it.due_date, it.allow_retakes, + null, // prereq_item_id: don't copy — old IDs won't match new items + it.evidence_mode || 'none', it.retake_policy || 'latest' + ).lastInsertRowid; + if (it.type === 'quiz') { + for (const q of getQs.all(it.id)) { + insQ.run(newItemId, q.type, q.prompt, q.choices, q.correct_answer, q.points, q.sort); + } + } + } + } + + res.json({ id: newCourseId }); +}); + app.post('/api/courses/:id/enroll', requirePin, (req, res) => { db.prepare(`INSERT OR IGNORE INTO enrollments (student_id, course_id) VALUES (?, ?)`) .run(req.body.studentId, req.params.id);