Add course duplicate and compact list/roster layout
- POST /api/courses/:id/duplicate deep-copies a course with all units, items, and quiz questions; opens the copy immediately for renaming - Roster changed from broken vertical stack to horizontal chip-pills (fixes admin-card label override that was collapsing inline-flex) - item-row padding reduced for tighter lists throughout admin - Courses and Kids admin panels now use a 2-column auto-fill grid Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca68e9df52
commit
0ae0db76bc
4 files changed
+68
-3
No files matched your search
@@ -76,6 +76,7 @@
|
|||||||
<input id="cd-color-input" type="color">
|
<input id="cd-color-input" type="color">
|
||||||
<label class="checkbox-label"><input type="checkbox" id="cd-archived"> Archived</label>
|
<label class="checkbox-label"><input type="checkbox" id="cd-archived"> Archived</label>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
|
<button type="button" id="cd-duplicate" class="secondary">Duplicate course</button>
|
||||||
<button type="button" id="cd-delete" class="danger">Delete course</button>
|
<button type="button" id="cd-delete" class="danger">Delete course</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -280,6 +280,13 @@ $('#course-meta-form').addEventListener('submit', async (e) => {
|
|||||||
openCourseDetail(currentCourseId);
|
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 () => {
|
$('#cd-delete').addEventListener('click', async () => {
|
||||||
if (!confirm('Delete this whole course, including all units, items, and grades?')) return;
|
if (!confirm('Delete this whole course, including all units, items, and grades?')) return;
|
||||||
await api(`/api/courses/${currentCourseId}`, { method: 'DELETE' });
|
await api(`/api/courses/${currentCourseId}`, { method: 'DELETE' });
|
||||||
|
|||||||
+23
-3
@@ -214,9 +214,9 @@ button.danger { background: var(--bad); }
|
|||||||
.inline-form input { flex: 1; min-width: 140px; }
|
.inline-form input { flex: 1; min-width: 140px; }
|
||||||
|
|
||||||
.item-row {
|
.item-row {
|
||||||
display: flex; align-items: center; gap: 0.75rem;
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
background: var(--card); border-radius: 12px; padding: 0.75rem 1rem; margin: 0.5rem 0;
|
background: var(--card); border-radius: 10px; padding: 0.45rem 0.75rem; margin: 0.2rem 0;
|
||||||
box-shadow: 0 2px 6px rgba(45, 49, 66, 0.06);
|
box-shadow: 0 1px 4px rgba(45, 49, 66, 0.06);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.item-row .grow { flex: 1; min-width: 120px; }
|
.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; }
|
.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; }
|
.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; }
|
.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.small { font-size: 0.85rem; padding: 0.4rem 0.8rem; }
|
||||||
button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; }
|
button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; }
|
||||||
.hint.tiny { font-size: 0.8rem; margin: 0.25rem 0; }
|
.hint.tiny { font-size: 0.8rem; margin: 0.25rem 0; }
|
||||||
|
|||||||
@@ -479,6 +479,43 @@ app.delete('/api/courses/:id', requirePin, (req, res) => {
|
|||||||
res.json({ ok: true });
|
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) => {
|
app.post('/api/courses/:id/enroll', requirePin, (req, res) => {
|
||||||
db.prepare(`INSERT OR IGNORE INTO enrollments (student_id, course_id) VALUES (?, ?)`)
|
db.prepare(`INSERT OR IGNORE INTO enrollments (student_id, course_id) VALUES (?, ?)`)
|
||||||
.run(req.body.studentId, req.params.id);
|
.run(req.body.studentId, req.params.id);
|
||||||
|
|||||||
Reference in new issue
Block a user